@goscribe/server 1.2.0 → 1.3.1
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.
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/context.d.ts +5 -1
- package/dist/lib/activity_human_description.d.ts +13 -0
- package/dist/lib/activity_human_description.js +221 -0
- package/dist/lib/activity_human_description.test.d.ts +1 -0
- package/dist/lib/activity_human_description.test.js +16 -0
- package/dist/lib/activity_log_service.d.ts +87 -0
- package/dist/lib/activity_log_service.js +276 -0
- package/dist/lib/activity_log_service.test.d.ts +1 -0
- package/dist/lib/activity_log_service.test.js +27 -0
- package/dist/lib/ai-session.d.ts +15 -2
- package/dist/lib/ai-session.js +147 -85
- package/dist/lib/constants.d.ts +13 -0
- package/dist/lib/constants.js +12 -0
- package/dist/lib/email.d.ts +11 -0
- package/dist/lib/email.js +193 -0
- package/dist/lib/env.d.ts +13 -0
- package/dist/lib/env.js +16 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +3 -3
- package/dist/lib/logger.d.ts +4 -4
- package/dist/lib/logger.js +30 -8
- package/dist/lib/notification-service.d.ts +152 -0
- package/dist/lib/notification-service.js +473 -0
- package/dist/lib/notification-service.test.d.ts +1 -0
- package/dist/lib/notification-service.test.js +87 -0
- package/dist/lib/prisma.d.ts +2 -1
- package/dist/lib/prisma.js +5 -1
- package/dist/lib/pusher.d.ts +23 -0
- package/dist/lib/pusher.js +69 -5
- package/dist/lib/retry.d.ts +15 -0
- package/dist/lib/retry.js +37 -0
- package/dist/lib/storage.js +2 -2
- package/dist/lib/stripe.d.ts +9 -0
- package/dist/lib/stripe.js +36 -0
- package/dist/lib/subscription_service.d.ts +37 -0
- package/dist/lib/subscription_service.js +654 -0
- package/dist/lib/usage_service.d.ts +26 -0
- package/dist/lib/usage_service.js +59 -0
- package/dist/lib/worksheet-generation.d.ts +91 -0
- package/dist/lib/worksheet-generation.js +95 -0
- package/dist/lib/worksheet-generation.test.d.ts +1 -0
- package/dist/lib/worksheet-generation.test.js +20 -0
- package/dist/lib/workspace-access.d.ts +18 -0
- package/dist/lib/workspace-access.js +13 -0
- package/dist/routers/_app.d.ts +1349 -253
- package/dist/routers/_app.js +10 -0
- package/dist/routers/admin.d.ts +361 -0
- package/dist/routers/admin.js +633 -0
- package/dist/routers/annotations.d.ts +219 -0
- package/dist/routers/annotations.js +187 -0
- package/dist/routers/auth.d.ts +88 -7
- package/dist/routers/auth.js +339 -19
- package/dist/routers/chat.d.ts +6 -12
- package/dist/routers/copilot.d.ts +199 -0
- package/dist/routers/copilot.js +571 -0
- package/dist/routers/flashcards.d.ts +47 -81
- package/dist/routers/flashcards.js +143 -27
- package/dist/routers/members.d.ts +36 -7
- package/dist/routers/members.js +200 -19
- package/dist/routers/notifications.d.ts +99 -0
- package/dist/routers/notifications.js +127 -0
- package/dist/routers/payment.d.ts +89 -0
- package/dist/routers/payment.js +403 -0
- package/dist/routers/podcast.d.ts +8 -13
- package/dist/routers/podcast.js +54 -31
- package/dist/routers/studyguide.d.ts +1 -29
- package/dist/routers/studyguide.js +80 -71
- package/dist/routers/worksheets.d.ts +105 -38
- package/dist/routers/worksheets.js +258 -68
- package/dist/routers/workspace.d.ts +139 -60
- package/dist/routers/workspace.js +455 -315
- package/dist/scripts/purge-deleted-users.d.ts +1 -0
- package/dist/scripts/purge-deleted-users.js +149 -0
- package/dist/server.js +130 -10
- package/dist/services/flashcard-progress.service.d.ts +18 -66
- package/dist/services/flashcard-progress.service.js +51 -42
- package/dist/trpc.d.ts +20 -21
- package/dist/trpc.js +150 -1
- package/mcq-test.cjs +36 -0
- package/package.json +9 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +471 -324
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +79 -51
- package/src/lib/email.ts +213 -29
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +2 -2
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +86 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/routers/_app.ts +9 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +41 -0
- package/src/routers/auth.ts +338 -28
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +201 -68
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +112 -83
- package/src/routers/studyguide.ts +12 -0
- package/src/routers/worksheets.ts +289 -66
- package/src/routers/workspace.ts +329 -122
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +137 -11
- package/src/services/flashcard-progress.service.ts +49 -37
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
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
|
-
|
|
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
|
+
}
|