/** * Phase 8 (governance & production hardening) — org ANALYTICS endpoint. * * `GET /orgs/:orgId/admin/analytics` — read-only, ORG-scoped usage/health * analytics for the org console (the "Complete analytics" Phase 8 item). * Same house pattern as `overview.service.ts`: * - admin-only (`assertOrgAdmin` — server-side authz, never a UI gate), * - all rows filtered to the ORG's workspaces (`workspace.organizationId` * for content; `organizationId` for org membership), * - trends are zero-filled UTC-day buckets (no rollup table in v1 — the * overview service sets the precedent). * * The numbers are point-in-time counts/trends, NOT billing (billing stays on * `usage.service.ts` semantics). This is an additive endpoint — it does not * touch the overview payload. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { PrismaService } from "../../common/prisma.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; const DAY_MS = 86_400_000; /** Zero-fill `n` UTC days ending today (2026-10-11 … the day before, for n=30). */ function dayKeys(n: number): string[] { const keys: string[] = []; for (let i = n - 1; i >= 0; i--) { keys.push(new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10)); } return keys; } @Injectable() export class OrgAnalyticsService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async analytics(orgId: string, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); // Content counts are org-scoped through the workspace relation (the // overview service's approach — works for every content model). const inOrg = { workspace: { organizationId: orgId } } as const; const [ workspaces, roleAgg, pageByKind, newPagesRows, newDatabases30, commentsOpen, comments30, filesAgg, files30, audit30, auditByAction, membersList ] = await Promise.all([ this.prisma.workspace.findMany({ where: { organizationId: orgId }, select: { id: true, archivedAt: true, createdAt: true } }), this.prisma.organizationMember.groupBy({ by: ["role"], where: { organizationId: orgId }, _count: { _all: true } }), this.prisma.page.groupBy({ by: ["kind"], where: { ...inOrg, trashedAt: null }, _count: { _all: true } }), this.prisma.page.findMany({ where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }, select: { createdAt: true } }), this.prisma.database.count({ where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } } }), this.prisma.comment.count({ where: { ...inOrg, status: "open" } }), this.prisma.comment.count({ where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } } }), this.prisma.file.aggregate({ where: { ...inOrg, deletedAt: null }, _sum: { size: true }, _count: { _all: true } }), this.prisma.file.findMany({ where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }, select: { size: true, createdAt: true } }), this.prisma.auditLog.groupBy({ by: ["createdAt"], where: { OR: [{ workspace: { organizationId: orgId } }, { workspaceId: null }], createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }, _count: { _all: true } }), this.prisma.auditLog.groupBy({ by: ["action"], where: { OR: [{ workspace: { organizationId: orgId } }, { workspaceId: null }], createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }, _count: { _all: true } }), this.prisma.organizationMember.findMany({ where: { organizationId: orgId }, select: { userId: true, createdAt: true } }) ]); // Zero-filled 30-day buckets (UTC day, ISO date string). const days = dayKeys(30); const zeros = (): Map => new Map(days.map((d) => [d, 0] as [string, number])); const pageByDay = zeros(); const fileByDay = zeros(); const memberByDay = zeros(); const auditByDay = zeros(); // 7-day slice of the same 30-day fetch (no extra round trip). const weekAgo = Date.now() - 6 * DAY_MS; const newPages7 = newPagesRows.filter((p) => p.createdAt.getTime() >= weekAgo).length; for (const p of newPagesRows) { const d = p.createdAt.toISOString().slice(0, 10); if (pageByDay.has(d)) pageByDay.set(d, (pageByDay.get(d) ?? 0) + 1); } for (const f of files30) { const d = f.createdAt.toISOString().slice(0, 10); if (fileByDay.has(d)) fileByDay.set(d, (fileByDay.get(d) ?? 0) + f.size); } for (const a of audit30) { const d = a.createdAt.toISOString().slice(0, 10); if (auditByDay.has(d)) auditByDay.set(d, (auditByDay.get(d) ?? 0) + a._count._all); } for (const m of membersList) { const d = m.createdAt.toISOString().slice(0, 10); if (memberByDay.has(d)) memberByDay.set(d, (memberByDay.get(d) ?? 0) + 1); } const pageCounts = Object.fromEntries(pageByKind.map((r) => [r.kind, r._count._all])); const roles = Object.fromEntries(roleAgg.map((r) => [r.role, r._count._all])); const topActions = auditByAction .map((r) => ({ action: r.action, count: r._count._all })) .sort((a, b) => b.count - a.count) .slice(0, 10); return { data: { workspaces: workspaces.length, archivedWorkspaces: workspaces.filter((w) => w.archivedAt !== null).length, members: membersList.length, membersByRole: roles, pages: pageCounts, pagesTotal: pageByKind.reduce((s, r) => s + r._count._all, 0), newPages: { last30d: newPagesRows.length, last7d: newPages7 }, newDatabases30d: newDatabases30, comments: { open: commentsOpen, last30d: comments30 }, files: { count: filesAgg._count._all, storageBytes: filesAgg._sum.size ?? 0, last30dBytes: files30.reduce((s, f) => s + f.size, 0) }, audit: { last30d: audit30.reduce((s, a) => s + a._count._all, 0), topActions }, trends: { newPages: [...pageByDay.entries()].map(([day, count]) => ({ day, count })), storage: [...fileByDay.entries()].map(([day, bytes]) => ({ day, bytes })), members: [...memberByDay.entries()].map(([day, count]) => ({ day, count })), audit: [...auditByDay.entries()].map(([day, count]) => ({ day, count })) } } }; } }