Skip to content

Commit cfbeb28

Browse files
authored
Merge pull request #2495 from trycompai/feat/admin-org-activity-endpoint
feat: admin org activity endpoint
2 parents 7be96c0 + 4023cf1 commit cfbeb28

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

apps/api/src/admin-organizations/admin-organizations.controller.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ export class AdminOrganizationsController {
4242
});
4343
}
4444

45+
@Get('activity')
46+
@ApiOperation({ summary: 'Organization activity report - shows last session per org (platform admin)' })
47+
@ApiQuery({ name: 'inactiveDays', required: false, description: 'Filter orgs with no session in N days (default: 90)' })
48+
@ApiQuery({ name: 'hasAccess', required: false, description: 'Filter by hasAccess (true/false)' })
49+
@ApiQuery({ name: 'onboarded', required: false, description: 'Filter by onboardingCompleted (true/false)' })
50+
@ApiQuery({ name: 'page', required: false })
51+
@ApiQuery({ name: 'limit', required: false })
52+
async activity(
53+
@Query('inactiveDays') inactiveDays?: string,
54+
@Query('hasAccess') hasAccess?: string,
55+
@Query('onboarded') onboarded?: string,
56+
@Query('page') page?: string,
57+
@Query('limit') limit?: string,
58+
) {
59+
return this.service.getOrgActivity({
60+
inactiveDays: Math.max(0, Number.isFinite(parseInt(inactiveDays ?? '90', 10)) ? parseInt(inactiveDays ?? '90', 10) : 90),
61+
hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined,
62+
onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined,
63+
page: Math.max(1, parseInt(page || '1', 10) || 1),
64+
limit: Math.min(100, Math.max(1, parseInt(limit || '50', 10) || 50)),
65+
});
66+
}
67+
4568
@Get(':id')
4669
@ApiOperation({ summary: 'Get organization details (platform admin)' })
4770
async get(@Param('id') id: string) {

apps/api/src/admin-organizations/admin-organizations.service.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,119 @@ export class AdminOrganizationsService {
100100
};
101101
}
102102

103+
async getOrgActivity(options: {
104+
inactiveDays: number;
105+
hasAccess?: boolean;
106+
onboarded?: boolean;
107+
page: number;
108+
limit: number;
109+
}) {
110+
const { inactiveDays, hasAccess, onboarded, page, limit } = options;
111+
const skip = (page - 1) * limit;
112+
const cutoff = new Date();
113+
cutoff.setDate(cutoff.getDate() - inactiveDays);
114+
115+
const where: Record<string, unknown> = {};
116+
if (hasAccess !== undefined) where.hasAccess = hasAccess;
117+
if (onboarded !== undefined) where.onboardingCompleted = onboarded;
118+
119+
const [organizations, total] = await Promise.all([
120+
db.organization.findMany({
121+
where,
122+
select: {
123+
id: true,
124+
name: true,
125+
createdAt: true,
126+
hasAccess: true,
127+
onboardingCompleted: true,
128+
_count: { select: { members: true, tasks: true, policy: true, auditLog: true } },
129+
members: {
130+
where: { deactivated: false },
131+
select: {
132+
role: true,
133+
user: {
134+
select: {
135+
id: true,
136+
name: true,
137+
email: true,
138+
sessions: {
139+
orderBy: { updatedAt: 'desc' as const },
140+
take: 1,
141+
select: { updatedAt: true },
142+
},
143+
},
144+
},
145+
},
146+
},
147+
auditLog: {
148+
orderBy: { timestamp: 'desc' as const },
149+
take: 1,
150+
select: { timestamp: true },
151+
},
152+
},
153+
orderBy: { createdAt: 'desc' },
154+
skip,
155+
take: limit,
156+
}),
157+
db.organization.count({ where }),
158+
]);
159+
160+
// Post-process to find last activity per org
161+
const data = organizations.map((org) => {
162+
let lastSession: Date | null = null;
163+
let owner: { id: string; name: string; email: string } | null = null;
164+
165+
for (const member of org.members) {
166+
const sess = member.user?.sessions?.[0]?.updatedAt;
167+
if (sess && (!lastSession || sess > lastSession)) {
168+
lastSession = sess;
169+
}
170+
if (member.role?.includes('owner') && !owner) {
171+
owner = { id: member.user.id, name: member.user.name, email: member.user.email };
172+
}
173+
}
174+
175+
const lastAuditLog = org.auditLog?.[0]?.timestamp ?? null;
176+
const lastActivity = [lastSession, lastAuditLog]
177+
.filter(Boolean)
178+
.sort((a, b) => (b as Date).getTime() - (a as Date).getTime())[0] as Date | undefined;
179+
180+
const isActive = lastActivity ? lastActivity >= cutoff : false;
181+
182+
return {
183+
id: org.id,
184+
name: org.name,
185+
createdAt: org.createdAt,
186+
hasAccess: org.hasAccess,
187+
onboardingCompleted: org.onboardingCompleted,
188+
memberCount: org._count.members,
189+
taskCount: org._count.tasks,
190+
policyCount: org._count.policy,
191+
auditLogCount: org._count.auditLog,
192+
owner,
193+
lastSession: lastSession?.toISOString() ?? null,
194+
lastAuditLog: lastAuditLog ? (lastAuditLog as Date).toISOString() : null,
195+
lastActivity: lastActivity?.toISOString() ?? null,
196+
isActive,
197+
};
198+
});
199+
200+
const activeCount = data.filter((d) => d.isActive).length;
201+
const inactiveCount = data.filter((d) => !d.isActive).length;
202+
203+
return {
204+
data,
205+
total,
206+
page,
207+
limit,
208+
summary: {
209+
inactiveDays,
210+
activeInPage: activeCount,
211+
inactiveInPage: inactiveCount,
212+
},
213+
};
214+
}
215+
103216
async getOrganization(id: string) {
104217
const org = await db.organization.findUnique({
105218
where: { id },

0 commit comments

Comments
 (0)