@goscribe/server 1.1.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
package/src/context.ts CHANGED
@@ -17,4 +17,7 @@ export async function createContext({ req, res }: CreateExpressContextOptions) {
17
17
  return { db: prisma, session: null, req, res, cookies };
18
18
  }
19
19
 
20
- export type Context = Awaited<ReturnType<typeof createContext>>;
20
+ /** Use `typeof prisma` for `db` so TS keeps generated model delegates (not a bare PrismaClient). */
21
+ export type Context = Omit<Awaited<ReturnType<typeof createContext>>, "db"> & {
22
+ db: typeof prisma;
23
+ };
@@ -0,0 +1,28 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ getActivityHumanDescription,
5
+ getTrpcPathsMatchingDescriptionSearch,
6
+ } from "./activity_human_description.js";
7
+
8
+ test("description search resolves paths by label substring", () => {
9
+ const paths = getTrpcPathsMatchingDescriptionSearch("study streak");
10
+ assert.ok(paths.includes("workspace.getStudyAnalytics"));
11
+ });
12
+
13
+ test("known path returns curated label", () => {
14
+ assert.equal(
15
+ getActivityHumanDescription("workspace.getStudyAnalytics"),
16
+ "Loaded study streak and analytics"
17
+ );
18
+ assert.equal(
19
+ getActivityHumanDescription("payment.getUsageOverview"),
20
+ "Viewed plan usage and limits"
21
+ );
22
+ });
23
+
24
+ test("unknown path gets formatted fallback", () => {
25
+ const d = getActivityHumanDescription("someRouter.unknownProcedureName");
26
+ assert.ok(d.includes("Some Router"));
27
+ assert.ok(d.includes("Unknown"));
28
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * User-facing labels for tRPC paths shown in activity logs.
3
+ * Unknown paths get a best-effort title from the procedure name.
4
+ */
5
+
6
+ const PATH_LABELS: Record<string, string> = {
7
+ // Admin
8
+ "admin.getSystemStats": "Viewed system dashboard statistics",
9
+ "admin.listUsers": "Listed users (admin)",
10
+ "admin.listWorkspaces": "Listed workspaces (admin)",
11
+ "admin.updateUserRole": "Changed a user’s role",
12
+ "admin.listPlans": "Listed subscription plans",
13
+ "admin.upsertPlan": "Created or updated a plan",
14
+ "admin.deletePlan": "Deleted or deactivated a plan",
15
+ "admin.getUserInvoices": "Viewed a user’s invoices",
16
+ "admin.getUserDetailedInfo": "Viewed a user’s profile (admin)",
17
+ "admin.debugInvoices": "Ran invoice debug (admin)",
18
+ "admin.listResourcePrices": "Listed resource prices",
19
+ "admin.upsertResourcePrice": "Updated a resource price",
20
+ "admin.listRecentInvoices": "Viewed recent invoices",
21
+ "admin.activityList": "Viewed activity logs (admin)",
22
+ "admin.activityExportCsv": "Exported activity logs to CSV",
23
+ "admin.activityPurgeRetention": "Purged old activity logs (retention)",
24
+
25
+ // Workspace
26
+ "workspace.list": "Listed your workspaces",
27
+ "workspace.getTree": "Loaded folders and workspace tree",
28
+ "workspace.create": "Created a workspace",
29
+ "workspace.createFolder": "Created a folder",
30
+ "workspace.updateFolder": "Updated a folder",
31
+ "workspace.deleteFolder": "Deleted a folder",
32
+ "workspace.get": "Opened a workspace",
33
+ "workspace.getStats": "Loaded storage and usage stats",
34
+ "workspace.getStudyAnalytics": "Loaded study streak and analytics",
35
+ "workspace.update": "Updated workspace settings",
36
+ "workspace.delete": "Deleted a workspace",
37
+ "workspace.getFolderInformation": "Loaded folder details",
38
+ "workspace.getSharedWith": "Loaded who a workspace is shared with",
39
+ "workspace.uploadFiles": "Uploaded files",
40
+ "workspace.deleteFiles": "Deleted files",
41
+ "workspace.getFileUploadUrl": "Requested an upload URL",
42
+ "workspace.uploadAndAnalyzeMedia": "Uploaded and analyzed media",
43
+ "workspace.search": "Searched workspaces and content",
44
+
45
+ // Payment
46
+ "payment.getPlans": "Viewed available plans",
47
+ "payment.createCheckoutSession": "Started checkout",
48
+ "payment.confirmCheckoutSuccess": "Confirmed checkout",
49
+ "payment.createResourcePurchaseSession": "Started a resource purchase",
50
+ "payment.getUsageOverview": "Viewed plan usage and limits",
51
+ "payment.getResourcePrices": "Viewed resource prices",
52
+
53
+ // Notifications
54
+ "notifications.list": "Loaded notifications",
55
+ "notifications.unreadCount": "Checked unread notifications",
56
+ "notifications.markRead": "Marked a notification as read",
57
+ "notifications.markManyRead": "Marked notifications as read",
58
+ "notifications.markAllRead": "Marked all notifications as read",
59
+ "notifications.delete": "Deleted a notification",
60
+
61
+ // Members
62
+ "members.getMembers": "Listed workspace members",
63
+ "members.getCurrentUserRole": "Checked your role in a workspace",
64
+ "members.inviteMember": "Sent a workspace invite",
65
+ "members.getInvitations": "Listed pending invites",
66
+ "members.acceptInvite": "Accepted a workspace invite",
67
+ "members.changeMemberRole": "Changed a member’s role",
68
+ "members.removeMember": "Removed a workspace member",
69
+ "members.getPendingInvitations": "Listed invitations",
70
+ "members.cancelInvitation": "Canceled an invitation",
71
+ "members.resendInvitation": "Resent an invitation",
72
+ "members.getAllInvitationsDebug": "Listed invitations (debug)",
73
+ "member.acceptInvite": "Accepted a workspace invite",
74
+
75
+ // Flashcards
76
+ "flashcards.listSets": "Listed flashcard sets",
77
+ "flashcards.listCards": "Listed flashcards",
78
+ "flashcards.isGenerating": "Checked flashcard generation status",
79
+ "flashcards.createCard": "Created a flashcard",
80
+ "flashcards.updateCard": "Updated a flashcard",
81
+ "flashcards.gradeTypedAnswer": "Graded a flashcard answer",
82
+ "flashcards.deleteCard": "Deleted a flashcard",
83
+ "flashcards.deleteSet": "Deleted a flashcard set",
84
+ "flashcards.generateFromPrompt": "Generated flashcards from a prompt",
85
+ "flashcards.recordStudyAttempt": "Recorded a flashcard review",
86
+ "flashcards.getSetProgress": "Viewed flashcard progress",
87
+ "flashcards.getDueFlashcards": "Loaded due flashcards",
88
+ "flashcards.getSetStatistics": "Viewed flashcard statistics",
89
+ "flashcards.resetProgress": "Reset flashcard progress",
90
+ "flashcards.recordStudySession": "Recorded a study session",
91
+
92
+ // Worksheets
93
+ "worksheets.list": "Listed worksheets",
94
+ "worksheets.listPresets": "Listed worksheet presets",
95
+ "worksheets.createPreset": "Created a worksheet preset",
96
+ "worksheets.updatePreset": "Updated a worksheet preset",
97
+ "worksheets.deletePreset": "Deleted a worksheet preset",
98
+ "worksheets.create": "Created a worksheet",
99
+ "worksheets.get": "Opened a worksheet",
100
+ "worksheets.createWorksheetQuestion": "Added a worksheet question",
101
+ "worksheets.updateWorksheetQuestion": "Updated a worksheet question",
102
+ "worksheets.deleteWorksheetQuestion": "Deleted a worksheet question",
103
+ "worksheets.updateProblemStatus": "Updated worksheet problem status",
104
+ "worksheets.getProgress": "Viewed worksheet progress",
105
+ "worksheets.update": "Updated a worksheet",
106
+ "worksheets.delete": "Deleted a worksheet",
107
+ "worksheets.generateFromPrompt": "Generated a worksheet from a prompt",
108
+ "worksheets.checkAnswer": "Checked a worksheet answer",
109
+
110
+ // Podcast
111
+ "podcast.listEpisodes": "Listed podcast episodes",
112
+ "podcast.getEpisode": "Opened a podcast episode",
113
+ "podcast.generateEpisode": "Generated a podcast episode",
114
+ "podcast.deleteSegment": "Deleted a podcast segment",
115
+ "podcast.getEpisodeSchema": "Loaded podcast episode schema",
116
+ "podcast.updateEpisode": "Updated a podcast episode",
117
+ "podcast.deleteEpisode": "Deleted a podcast episode",
118
+ "podcast.getSegment": "Opened a podcast segment",
119
+ "podcast.getAvailableVoices": "Listed podcast voices",
120
+
121
+ // Study guide
122
+ "studyguide.get": "Opened or loaded a study guide",
123
+
124
+ // Chat
125
+ "chat.getChannels": "Listed chat channels",
126
+ "chat.getChannel": "Opened a chat channel",
127
+ "chat.removeChannel": "Removed a chat channel",
128
+ "chat.editChannel": "Renamed a chat channel",
129
+ "chat.createChannel": "Created a chat channel",
130
+ "chat.postMessage": "Sent a chat message",
131
+ "chat.editMessage": "Edited a chat message",
132
+ "chat.deleteMessage": "Deleted a chat message",
133
+
134
+ // Annotations
135
+ "annotations.listHighlights": "Listed study guide highlights",
136
+ "annotations.createHighlight": "Created a highlight",
137
+ "annotations.deleteHighlight": "Deleted a highlight",
138
+ "annotations.addComment": "Added a comment",
139
+ "annotations.updateComment": "Updated a comment",
140
+ "annotations.deleteComment": "Deleted a comment",
141
+
142
+ // Copilot
143
+ "copilot.listConversations": "Listed Copilot chats",
144
+ "copilot.getConversation": "Opened a Copilot chat",
145
+ "copilot.createConversation": "Started a Copilot chat",
146
+ "copilot.deleteConversation": "Deleted a Copilot chat",
147
+ "copilot.ask": "Sent a Copilot message",
148
+ "copilot.explainSelection": "Asked Copilot to explain a selection",
149
+ "copilot.suggestHighlights": "Asked Copilot for highlight suggestions",
150
+ "copilot.generateFlashcards": "Generated flashcards with Copilot",
151
+
152
+ // Auth (if ever logged)
153
+ "auth.updateProfile": "Updated profile",
154
+ "auth.uploadProfilePicture": "Uploaded a profile picture",
155
+ "auth.confirmProfileUpdate": "Confirmed profile update",
156
+ "auth.signup": "Created an account",
157
+ "auth.verifyEmail": "Verified email",
158
+ "auth.resendVerification": "Resent verification email",
159
+ "auth.login": "Signed in",
160
+ "auth.getSession": "Loaded session",
161
+ "auth.requestAccountDeletion": "Requested account deletion",
162
+ "auth.restoreAccount": "Restored account",
163
+ "auth.logout": "Signed out",
164
+ };
165
+
166
+ function splitCamelCase(s: string): string {
167
+ const spaced = s.replace(/([a-z])([A-Z])/g, "$1 $2");
168
+ return spaced.replace(/^\w/, (c) => c.toUpperCase());
169
+ }
170
+
171
+ const ROUTER_PREFIX: Record<string, string> = {
172
+ workspace: "Workspace",
173
+ payment: "Billing",
174
+ notifications: "Notifications",
175
+ admin: "Admin",
176
+ auth: "Account",
177
+ flashcards: "Flashcards",
178
+ worksheets: "Worksheets",
179
+ podcast: "Podcast",
180
+ studyguide: "Study guide",
181
+ chat: "Chat",
182
+ annotations: "Annotations",
183
+ copilot: "Copilot",
184
+ members: "Members",
185
+ };
186
+
187
+ function titleCaseRouter(router: string): string {
188
+ return ROUTER_PREFIX[router] ?? splitCamelCase(router);
189
+ }
190
+
191
+ /**
192
+ * Paths whose curated description contains `query` (case-insensitive).
193
+ * Used so activity log search matches human-readable text, not only raw paths.
194
+ */
195
+ export function getTrpcPathsMatchingDescriptionSearch(query: string): string[] {
196
+ const q = query.trim().toLowerCase();
197
+ if (!q) return [];
198
+ const paths: string[] = [];
199
+ for (const [path, label] of Object.entries(PATH_LABELS)) {
200
+ if (label.toLowerCase().includes(q)) {
201
+ paths.push(path);
202
+ }
203
+ }
204
+ return paths;
205
+ }
206
+
207
+ /**
208
+ * Stable, human-readable line for UI and CSV exports.
209
+ */
210
+ export function getActivityHumanDescription(
211
+ trpcPath: string | null | undefined,
212
+ actionFallback?: string | null
213
+ ): string {
214
+ const path = (trpcPath ?? "").trim();
215
+ if (path) {
216
+ const exact = PATH_LABELS[path];
217
+ if (exact) return exact;
218
+ const parts = path.split(".").filter(Boolean);
219
+ if (parts.length >= 2) {
220
+ const proc = parts[parts.length - 1]!;
221
+ const ns = parts[0]!;
222
+ const rest = parts.slice(1, -1);
223
+ const action = splitCamelCase(proc);
224
+ const area = titleCaseRouter(ns);
225
+ if (rest.length) {
226
+ return `${area} (${rest.join(" › ")}): ${action}`;
227
+ }
228
+ return `${area}: ${action}`;
229
+ }
230
+ return path;
231
+ }
232
+ const act = (actionFallback ?? "").replace(/^trpc\./, "");
233
+ if (act) {
234
+ const exact = PATH_LABELS[act];
235
+ if (exact) return exact;
236
+ return act.replace(/\./g, " › ");
237
+ }
238
+ return "Activity";
239
+ }
@@ -0,0 +1,37 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ActivityLogCategory, ActivityLogStatus } from "@prisma/client";
4
+ import {
5
+ buildActivityLogWhere,
6
+ inferCategoryFromTrpcPath,
7
+ redactSensitive,
8
+ } from "./activity_log_service.js";
9
+
10
+ test("redactSensitive redacts sensitive keys", () => {
11
+ const out = redactSensitive({
12
+ email: "a@b.com",
13
+ password: "secret",
14
+ nested: { authToken: "t", safe: 1 },
15
+ }) as Record<string, unknown>;
16
+ assert.equal(out.password, "[REDACTED]");
17
+ const nested = out.nested as Record<string, unknown>;
18
+ assert.equal(nested.authToken, "[REDACTED]");
19
+ assert.equal(nested.safe, 1);
20
+ });
21
+
22
+ test("inferCategoryFromTrpcPath maps routers", () => {
23
+ assert.equal(inferCategoryFromTrpcPath("auth.login"), ActivityLogCategory.AUTH);
24
+ assert.equal(inferCategoryFromTrpcPath("payment.checkout"), ActivityLogCategory.BILLING);
25
+ assert.equal(inferCategoryFromTrpcPath("admin.listUsers"), ActivityLogCategory.ADMIN);
26
+ assert.equal(inferCategoryFromTrpcPath("workspace.list"), ActivityLogCategory.WORKSPACE);
27
+ assert.equal(inferCategoryFromTrpcPath("flashcards.list"), ActivityLogCategory.CONTENT);
28
+ });
29
+
30
+ test("buildActivityLogWhere forces actor for user scope", () => {
31
+ const w = buildActivityLogWhere(
32
+ { status: ActivityLogStatus.SUCCESS },
33
+ { forceActorUserId: "user-1" }
34
+ );
35
+ assert.equal(w.actorUserId, "user-1");
36
+ assert.equal(w.status, ActivityLogStatus.SUCCESS);
37
+ });
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Activity log persistence (append-only). Env:
3
+ * - ACTIVITY_LOG_ENABLED=true|false (default true)
4
+ * - ACTIVITY_LOG_SAMPLE_RATE=0..1 (default 1)
5
+ * - ACTIVITY_LOG_RETENTION_DAYS (default 365; used by admin.activityPurgeRetention)
6
+ */
7
+ import type { Prisma, PrismaClient } from "@prisma/client";
8
+ import { ActivityLogCategory, ActivityLogStatus } from "@prisma/client";
9
+ import type { IncomingMessage } from "node:http";
10
+ import { logger } from "./logger.js";
11
+ import { getTrpcPathsMatchingDescriptionSearch } from "./activity_human_description.js";
12
+
13
+ const SENSITIVE_KEY_FRAGMENTS = [
14
+ "password",
15
+ "token",
16
+ "secret",
17
+ "authorization",
18
+ "cookie",
19
+ "credit",
20
+ "card",
21
+ "cvv",
22
+ "ssn",
23
+ ];
24
+
25
+ export function isActivityLogEnabled(): boolean {
26
+ const v = process.env.ACTIVITY_LOG_ENABLED;
27
+ if (v === undefined || v === "") return true;
28
+ return v === "1" || v.toLowerCase() === "true";
29
+ }
30
+
31
+ function activitySampleRate(): number {
32
+ const raw = process.env.ACTIVITY_LOG_SAMPLE_RATE;
33
+ if (!raw) return 1;
34
+ const n = Number(raw);
35
+ if (!Number.isFinite(n) || n <= 0) return 0;
36
+ if (n > 1) return 1;
37
+ return n;
38
+ }
39
+
40
+ export function shouldSampleActivity(): boolean {
41
+ const rate = activitySampleRate();
42
+ if (rate >= 1) return true;
43
+ if (rate <= 0) return false;
44
+ return Math.random() < rate;
45
+ }
46
+
47
+ /** Recursive redaction for JSON-safe metadata (never log raw credentials). */
48
+ export function redactSensitive(value: unknown): unknown {
49
+ if (value === null || value === undefined) return value;
50
+ if (typeof value === "string") {
51
+ if (value.length > 2000) return `${value.slice(0, 2000)}…[truncated]`;
52
+ return value;
53
+ }
54
+ if (typeof value !== "object") return value;
55
+ if (Array.isArray(value)) {
56
+ return value.map((v) => redactSensitive(v));
57
+ }
58
+ const out: Record<string, unknown> = {};
59
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
60
+ const lower = k.toLowerCase();
61
+ if (SENSITIVE_KEY_FRAGMENTS.some((f) => lower.includes(f))) {
62
+ out[k] = "[REDACTED]";
63
+ continue;
64
+ }
65
+ out[k] = redactSensitive(v);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function inferCategoryFromTrpcPath(path: string): ActivityLogCategory {
71
+ const p = path.toLowerCase();
72
+ if (p.startsWith("auth.")) return ActivityLogCategory.AUTH;
73
+ if (p.startsWith("payment.")) return ActivityLogCategory.BILLING;
74
+ if (p.startsWith("admin.")) return ActivityLogCategory.ADMIN;
75
+ if (p.startsWith("workspace.") || p.startsWith("members.") || p.startsWith("member."))
76
+ return ActivityLogCategory.WORKSPACE;
77
+ if (
78
+ p.startsWith("flashcards.") ||
79
+ p.startsWith("worksheets.") ||
80
+ p.startsWith("studyguide.") ||
81
+ p.startsWith("podcast.") ||
82
+ p.startsWith("annotations.") ||
83
+ p.startsWith("chat.") ||
84
+ p.startsWith("copilot.")
85
+ )
86
+ return ActivityLogCategory.CONTENT;
87
+ if (p.startsWith("notifications.")) return ActivityLogCategory.SYSTEM;
88
+ return ActivityLogCategory.SYSTEM;
89
+ }
90
+
91
+ /** Best-effort workspace id from tRPC input (nested objects included). */
92
+ export function extractWorkspaceIdFromInput(raw: unknown): string | undefined {
93
+ if (raw === null || raw === undefined) return undefined;
94
+ if (typeof raw === "object" && !Array.isArray(raw)) {
95
+ const o = raw as Record<string, unknown>;
96
+ const direct = o.workspaceId ?? o.workspace_id;
97
+ if (typeof direct === "string" && direct.length > 0) return direct;
98
+ for (const v of Object.values(o)) {
99
+ const nested = extractWorkspaceIdFromInput(v);
100
+ if (nested) return nested;
101
+ }
102
+ }
103
+ if (Array.isArray(raw)) {
104
+ for (const item of raw) {
105
+ const nested = extractWorkspaceIdFromInput(item);
106
+ if (nested) return nested;
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ export function truncateUserAgent(ua: string | undefined, max = 512): string | undefined {
113
+ if (!ua) return undefined;
114
+ return ua.length > max ? ua.slice(0, max) : ua;
115
+ }
116
+
117
+ export function getClientIp(req: IncomingMessage): string | undefined {
118
+ const xf = req.headers["x-forwarded-for"];
119
+ if (typeof xf === "string") return xf.split(",")[0]?.trim();
120
+ if (Array.isArray(xf)) return xf[0]?.split(",")[0]?.trim();
121
+ const ra = req.socket?.remoteAddress;
122
+ return ra ?? undefined;
123
+ }
124
+
125
+ export type RecordActivityInput = {
126
+ db: PrismaClient;
127
+ actorUserId: string;
128
+ actorEmailSnapshot?: string | null;
129
+ path: string;
130
+ type: "query" | "mutation" | "subscription";
131
+ status: ActivityLogStatus;
132
+ durationMs: number;
133
+ errorCode?: string | null;
134
+ rawInput?: unknown;
135
+ ipAddress?: string | null;
136
+ userAgent?: string | null;
137
+ httpMethod?: string | null;
138
+ };
139
+
140
+ export type RecordExplicitActivityInput = {
141
+ db: PrismaClient;
142
+ actorUserId?: string | null;
143
+ actorEmailSnapshot?: string | null;
144
+ /**
145
+ * A stable action label shown in admin UI/CSV.
146
+ * Example: `cron.purgeDeletedUsers`, `stripe.webhook.checkout.session.completed`
147
+ */
148
+ action: string;
149
+ category: ActivityLogCategory;
150
+ resourceType?: string | null;
151
+ resourceId?: string | null;
152
+ workspaceId?: string | null;
153
+ trpcPath?: string | null;
154
+ httpMethod?: string | null;
155
+ status: ActivityLogStatus;
156
+ errorCode?: string | null;
157
+ durationMs: number;
158
+ ipAddress?: string | null;
159
+ userAgent?: string | null;
160
+ metadata?: unknown;
161
+ /**
162
+ * When true, bypasses ACTIVITY_LOG_SAMPLE_RATE sampling.
163
+ * Useful for low-volume admin-auditable system events (cron/webhooks).
164
+ */
165
+ forceWrite?: boolean;
166
+ };
167
+
168
+ function buildMetadata(path: string, rawInput: unknown | undefined): Prisma.InputJsonValue | undefined {
169
+ if (rawInput === undefined) return undefined;
170
+ try {
171
+ const redacted = redactSensitive(rawInput) as Prisma.InputJsonValue;
172
+ return { trpcInputPreview: redacted };
173
+ } catch {
174
+ return { trpcInputPreview: "[unserializable]" };
175
+ }
176
+ }
177
+
178
+ function buildExplicitMetadata(metadata: unknown | undefined): Prisma.InputJsonValue | undefined {
179
+ if (metadata === undefined) return undefined;
180
+ try {
181
+ return redactSensitive(metadata) as Prisma.InputJsonValue;
182
+ } catch {
183
+ return { metadata: "[unserializable]" } as Prisma.InputJsonValue;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Persists one activity row. Prefer `scheduleRecordActivity` from request path to avoid blocking.
189
+ */
190
+ export async function recordActivity(input: RecordActivityInput): Promise<void> {
191
+ if (!isActivityLogEnabled()) return;
192
+ if (!shouldSampleActivity()) return;
193
+
194
+ const category = inferCategoryFromTrpcPath(input.path);
195
+ const workspaceId = extractWorkspaceIdFromInput(input.rawInput) ?? undefined;
196
+ const action = `trpc.${input.path}`;
197
+ const metadata = buildMetadata(input.path, input.rawInput);
198
+
199
+ try {
200
+ await input.db.activityLog.create({
201
+ data: {
202
+ actorUserId: input.actorUserId,
203
+ actorEmailSnapshot: input.actorEmailSnapshot ?? undefined,
204
+ action,
205
+ category,
206
+ trpcPath: input.path,
207
+ httpMethod: input.httpMethod ?? undefined,
208
+ status: input.status,
209
+ errorCode: input.errorCode ?? undefined,
210
+ durationMs: input.durationMs,
211
+ workspaceId,
212
+ ipAddress: input.ipAddress ?? undefined,
213
+ userAgent: truncateUserAgent(input.userAgent ?? undefined),
214
+ metadata: metadata ?? undefined,
215
+ },
216
+ });
217
+ } catch (e) {
218
+ logger.error(
219
+ `ActivityLog write failed: ${e instanceof Error ? e.message : String(e)}`,
220
+ "ACTIVITY"
221
+ );
222
+ }
223
+ }
224
+
225
+ export function scheduleRecordActivity(input: RecordActivityInput): void {
226
+ void Promise.resolve()
227
+ .then(() => recordActivity(input))
228
+ .catch((e) =>
229
+ logger.error(
230
+ `ActivityLog async error: ${e instanceof Error ? e.message : String(e)}`,
231
+ "ACTIVITY"
232
+ )
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Persists one explicit (non-tRPC) activity row.
238
+ * This is used for cron jobs, webhooks, and other system-wide background operations.
239
+ */
240
+ export async function recordExplicitActivity(
241
+ input: RecordExplicitActivityInput
242
+ ): Promise<void> {
243
+ if (!isActivityLogEnabled()) return;
244
+ if (!input.forceWrite && !shouldSampleActivity()) return;
245
+
246
+ const metadata = buildExplicitMetadata(input.metadata);
247
+
248
+ try {
249
+ await input.db.activityLog.create({
250
+ data: {
251
+ actorUserId: input.actorUserId ?? undefined,
252
+ actorEmailSnapshot: input.actorEmailSnapshot ?? undefined,
253
+ action: input.action,
254
+ category: input.category,
255
+ resourceType: input.resourceType ?? undefined,
256
+ resourceId: input.resourceId ?? undefined,
257
+ workspaceId: input.workspaceId ?? undefined,
258
+ trpcPath: input.trpcPath ?? undefined,
259
+ httpMethod: input.httpMethod ?? undefined,
260
+ status: input.status,
261
+ errorCode: input.errorCode ?? undefined,
262
+ durationMs: input.durationMs,
263
+ ipAddress: input.ipAddress ?? undefined,
264
+ userAgent: truncateUserAgent(input.userAgent ?? undefined),
265
+ metadata: metadata ?? undefined,
266
+ },
267
+ });
268
+ } catch (e) {
269
+ logger.error(
270
+ `ActivityLog explicit write failed: ${
271
+ e instanceof Error ? e.message : String(e)
272
+ }`,
273
+ "ACTIVITY"
274
+ );
275
+ }
276
+ }
277
+
278
+ export function scheduleRecordExplicitActivity(
279
+ input: RecordExplicitActivityInput
280
+ ): void {
281
+ void Promise.resolve()
282
+ .then(() => recordExplicitActivity(input))
283
+ .catch((e) =>
284
+ logger.error(
285
+ `ActivityLog explicit async error: ${
286
+ e instanceof Error ? e.message : String(e)
287
+ }`,
288
+ "ACTIVITY"
289
+ )
290
+ );
291
+ }
292
+
293
+ /** Default retention: 365 days. Override with ACTIVITY_LOG_RETENTION_DAYS. */
294
+ export function getActivityRetentionDays(): number {
295
+ const raw = process.env.ACTIVITY_LOG_RETENTION_DAYS;
296
+ if (!raw) return 365;
297
+ const n = Number(raw);
298
+ if (!Number.isFinite(n) || n < 1) return 365;
299
+ return Math.min(Math.floor(n), 3650);
300
+ }
301
+
302
+ export async function deleteActivityLogsOlderThan(
303
+ db: PrismaClient,
304
+ cutoff: Date
305
+ ): Promise<{ deleted: number }> {
306
+ const result = await db.activityLog.deleteMany({
307
+ where: { createdAt: { lt: cutoff } },
308
+ });
309
+ return { deleted: result.count };
310
+ }
311
+
312
+ export type ActivityLogFilter = {
313
+ actorUserId?: string;
314
+ workspaceId?: string;
315
+ from?: Date;
316
+ to?: Date;
317
+ category?: ActivityLogCategory;
318
+ status?: ActivityLogStatus;
319
+ search?: string;
320
+ };
321
+
322
+ export function buildActivityLogWhere(
323
+ filter: ActivityLogFilter,
324
+ options?: { forceActorUserId?: string }
325
+ ): Prisma.ActivityLogWhereInput {
326
+ const where: Prisma.ActivityLogWhereInput = {};
327
+ if (options?.forceActorUserId) {
328
+ where.actorUserId = options.forceActorUserId;
329
+ } else if (filter.actorUserId) {
330
+ where.actorUserId = filter.actorUserId;
331
+ }
332
+ if (filter.workspaceId) where.workspaceId = filter.workspaceId;
333
+ if (filter.category) where.category = filter.category;
334
+ if (filter.status) where.status = filter.status;
335
+ if (filter.from || filter.to) {
336
+ where.createdAt = {};
337
+ if (filter.from) where.createdAt.gte = filter.from;
338
+ if (filter.to) where.createdAt.lte = filter.to;
339
+ }
340
+ if (filter.search?.trim()) {
341
+ const s = filter.search.trim();
342
+ const pathsFromDescription = getTrpcPathsMatchingDescriptionSearch(s);
343
+ where.OR = [
344
+ { action: { contains: s, mode: "insensitive" } },
345
+ { trpcPath: { contains: s, mode: "insensitive" } },
346
+ { errorCode: { contains: s, mode: "insensitive" } },
347
+ ...(pathsFromDescription.length > 0
348
+ ? [{ trpcPath: { in: pathsFromDescription } }]
349
+ : []),
350
+ ];
351
+ }
352
+ return where;
353
+ }