1
17import { Inject, Injectable } from "@nestjs/common";
18import type { Request } from "express";
19import { PrismaService } from "../../common/prisma.service.js";
20import { requireUser } from "../../common/types.js";
21import { assertOrgAdmin } from "./guards.js";
22
23const DAY_MS = 86_400_000;
24
25
26function dayKeys(n: number): string[] {
27 const keys: string[] = [];
28 for (let i = n - 1; i >= 0; i--) {
29 keys.push(new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10));
30 }
31 return keys;
32}
33
34@Injectable()
35export class OrgAnalyticsService {
36 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
37
38 async analytics(orgId: string, req: Request) {
39 const userId = requireUser(req);
40 await assertOrgAdmin(this.prisma, orgId, userId);
41
42
43
44 const inOrg = { workspace: { organizationId: orgId } } as const;
45
46 const [
47 workspaces,
48 roleAgg,
49 pageByKind,
50 newPagesRows,
51 newDatabases30,
52 commentsOpen,
53 comments30,
54 filesAgg,
55 files30,
56 audit30,
57 auditByAction,
58 membersList
59 ] = await Promise.all([
60 this.prisma.workspace.findMany({
61 where: { organizationId: orgId },
62 select: { id: true, archivedAt: true, createdAt: true }
63 }),
64 this.prisma.organizationMember.groupBy({
65 by: ["role"],
66 where: { organizationId: orgId },
67 _count: { _all: true }
68 }),
69 this.prisma.page.groupBy({
70 by: ["kind"],
71 where: { ...inOrg, trashedAt: null },
72 _count: { _all: true }
73 }),
74 this.prisma.page.findMany({
75 where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } },
76 select: { createdAt: true }
77 }),
78 this.prisma.database.count({
79 where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }
80 }),
81 this.prisma.comment.count({
82 where: { ...inOrg, status: "open" }
83 }),
84 this.prisma.comment.count({
85 where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } }
86 }),
87 this.prisma.file.aggregate({
88 where: { ...inOrg, deletedAt: null },
89 _sum: { size: true },
90 _count: { _all: true }
91 }),
92 this.prisma.file.findMany({
93 where: { ...inOrg, createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) } },
94 select: { size: true, createdAt: true }
95 }),
96 this.prisma.auditLog.groupBy({
97 by: ["createdAt"],
98 where: {
99 OR: [{ workspace: { organizationId: orgId } }, { workspaceId: null }],
100 createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) }
101 },
102 _count: { _all: true }
103 }),
104 this.prisma.auditLog.groupBy({
105 by: ["action"],
106 where: {
107 OR: [{ workspace: { organizationId: orgId } }, { workspaceId: null }],
108 createdAt: { gte: new Date(Date.now() - 29 * DAY_MS) }
109 },
110 _count: { _all: true }
111 }),
112 this.prisma.organizationMember.findMany({
113 where: { organizationId: orgId },
114 select: { userId: true, createdAt: true }
115 })
116 ]);
117
118
119 const days = dayKeys(30);
120 const zeros = (): Map<string, number> => new Map(days.map((d) => [d, 0] as [string, number]));
121 const pageByDay = zeros();
122 const fileByDay = zeros();
123 const memberByDay = zeros();
124 const auditByDay = zeros();
125
126
127 const weekAgo = Date.now() - 6 * DAY_MS;
128 const newPages7 = newPagesRows.filter((p) => p.createdAt.getTime() >= weekAgo).length;
129 for (const p of newPagesRows) {
130 const d = p.createdAt.toISOString().slice(0, 10);
131 if (pageByDay.has(d)) pageByDay.set(d, (pageByDay.get(d) ?? 0) + 1);
132 }
133 for (const f of files30) {
134 const d = f.createdAt.toISOString().slice(0, 10);
135 if (fileByDay.has(d)) fileByDay.set(d, (fileByDay.get(d) ?? 0) + f.size);
136 }
137 for (const a of audit30) {
138 const d = a.createdAt.toISOString().slice(0, 10);
139 if (auditByDay.has(d)) auditByDay.set(d, (auditByDay.get(d) ?? 0) + a._count._all);
140 }
141 for (const m of membersList) {
142 const d = m.createdAt.toISOString().slice(0, 10);
143 if (memberByDay.has(d)) memberByDay.set(d, (memberByDay.get(d) ?? 0) + 1);
144 }
145
146 const pageCounts = Object.fromEntries(pageByKind.map((r) => [r.kind, r._count._all]));
147 const roles = Object.fromEntries(roleAgg.map((r) => [r.role, r._count._all]));
148 const topActions = auditByAction
149 .map((r) => ({ action: r.action, count: r._count._all }))
150 .sort((a, b) => b.count - a.count)
151 .slice(0, 10);
152
153 return {
154 data: {
155 workspaces: workspaces.length,
156 archivedWorkspaces: workspaces.filter((w) => w.archivedAt !== null).length,
157 members: membersList.length,
158 membersByRole: roles,
159 pages: pageCounts,
160 pagesTotal: pageByKind.reduce((s, r) => s + r._count._all, 0),
161 newPages: { last30d: newPagesRows.length, last7d: newPages7 },
162 newDatabases30d: newDatabases30,
163 comments: { open: commentsOpen, last30d: comments30 },
164 files: {
165 count: filesAgg._count._all,
166 storageBytes: filesAgg._sum.size ?? 0,
167 last30dBytes: files30.reduce((s, f) => s + f.size, 0)
168 },
169 audit: { last30d: audit30.reduce((s, a) => s + a._count._all, 0), topActions },
170 trends: {
171 newPages: [...pageByDay.entries()].map(([day, count]) => ({ day, count })),
172 storage: [...fileByDay.entries()].map(([day, bytes]) => ({ day, bytes })),
173 members: [...memberByDay.entries()].map(([day, count]) => ({ day, count })),
174 audit: [...auditByDay.entries()].map(([day, count]) => ({ day, count }))
175 }
176 }
177 };
178 }
179}
180