@goscribe/server 1.2.0 → 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 (48) hide show
  1. package/check-difficulty.cjs +14 -0
  2. package/check-questions.cjs +14 -0
  3. package/db-summary.cjs +22 -0
  4. package/mcq-test.cjs +36 -0
  5. package/package.json +9 -2
  6. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  7. package/prisma/schema.prisma +471 -324
  8. package/src/context.ts +4 -1
  9. package/src/lib/activity_human_description.test.ts +28 -0
  10. package/src/lib/activity_human_description.ts +239 -0
  11. package/src/lib/activity_log_service.test.ts +37 -0
  12. package/src/lib/activity_log_service.ts +353 -0
  13. package/src/lib/ai-session.ts +79 -51
  14. package/src/lib/email.ts +213 -29
  15. package/src/lib/env.ts +23 -6
  16. package/src/lib/inference.ts +2 -2
  17. package/src/lib/notification-service.test.ts +106 -0
  18. package/src/lib/notification-service.ts +677 -0
  19. package/src/lib/prisma.ts +6 -1
  20. package/src/lib/pusher.ts +86 -2
  21. package/src/lib/stripe.ts +39 -0
  22. package/src/lib/subscription_service.ts +722 -0
  23. package/src/lib/usage_service.ts +74 -0
  24. package/src/lib/worksheet-generation.test.ts +31 -0
  25. package/src/lib/worksheet-generation.ts +139 -0
  26. package/src/routers/_app.ts +9 -0
  27. package/src/routers/admin.ts +710 -0
  28. package/src/routers/annotations.ts +41 -0
  29. package/src/routers/auth.ts +338 -28
  30. package/src/routers/copilot.ts +719 -0
  31. package/src/routers/flashcards.ts +201 -68
  32. package/src/routers/members.ts +280 -80
  33. package/src/routers/notifications.ts +142 -0
  34. package/src/routers/payment.ts +448 -0
  35. package/src/routers/podcast.ts +112 -83
  36. package/src/routers/studyguide.ts +12 -0
  37. package/src/routers/worksheets.ts +289 -66
  38. package/src/routers/workspace.ts +329 -122
  39. package/src/scripts/purge-deleted-users.ts +167 -0
  40. package/src/server.ts +137 -11
  41. package/src/services/flashcard-progress.service.ts +49 -37
  42. package/src/trpc.ts +184 -5
  43. package/test-generate.js +30 -0
  44. package/test-ratio.cjs +9 -0
  45. package/zod-test.cjs +22 -0
  46. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  47. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  48. package/prisma/seed.mjs +0 -135
@@ -0,0 +1,710 @@
1
+ import { z } from 'zod';
2
+ import { router, adminProcedure } from '../trpc.js';
3
+ import { logger } from '../lib/logger.js';
4
+ import { stripe } from '../lib/stripe.js';
5
+ import { ActivityLogCategory, ActivityLogStatus, ArtifactType, InvoiceType } from '@prisma/client';
6
+ import {
7
+ buildActivityLogWhere,
8
+ deleteActivityLogsOlderThan,
9
+ getActivityRetentionDays,
10
+ } from '../lib/activity_log_service.js';
11
+ import { getActivityHumanDescription } from '../lib/activity_human_description.js';
12
+
13
+ function csvEscapeCell(value: string | number | null | undefined): string {
14
+ if (value === null || value === undefined) return '';
15
+ const s = String(value);
16
+ if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
17
+ return s;
18
+ }
19
+
20
+ const activityLogFiltersInput = z.object({
21
+ from: z.coerce.date().optional(),
22
+ to: z.coerce.date().optional(),
23
+ actorUserId: z.string().optional(),
24
+ workspaceId: z.string().optional(),
25
+ category: z.nativeEnum(ActivityLogCategory).optional(),
26
+ status: z.nativeEnum(ActivityLogStatus).optional(),
27
+ search: z.string().max(200).optional(),
28
+ });
29
+
30
+ const activityListInput = activityLogFiltersInput.extend({
31
+ page: z.number().int().min(1).default(1),
32
+ limit: z.number().min(1).max(100).default(20),
33
+ });
34
+
35
+ const listUsersInput = z.object({
36
+ page: z.number().int().min(1).default(1),
37
+ pageSize: z.number().int().min(1).max(100).default(10),
38
+ /** Trims; matches name, email, or id (substring). */
39
+ search: z.string().max(200).optional(),
40
+ emailVerified: z.enum(['all', 'yes', 'no']).default('all'),
41
+ /** Filter by account creation time (joined date). */
42
+ joinedFrom: z.coerce.date().optional(),
43
+ joinedTo: z.coerce.date().optional(),
44
+ });
45
+
46
+ const listInvoicesInput = z.object({
47
+ page: z.number().int().min(1).default(1),
48
+ pageSize: z.number().int().min(1).max(100).default(10),
49
+ search: z.string().max(200).optional(),
50
+ status: z.string().max(32).optional(),
51
+ type: z.nativeEnum(InvoiceType).optional(),
52
+ userId: z.string().optional(),
53
+ from: z.coerce.date().optional(),
54
+ to: z.coerce.date().optional(),
55
+ });
56
+
57
+ export const admin = router({
58
+ getSystemStats: adminProcedure
59
+ .query(async ({ ctx }) => {
60
+ const totalUsers = await ctx.db.user.count();
61
+ const totalWorkspaces = await ctx.db.workspace.count();
62
+ const totalSubscriptions = await ctx.db.subscription.count({
63
+ where: { status: 'active' }
64
+ });
65
+
66
+ // Calculate revenue breakdown
67
+ const subRevenueResult = await (ctx.db as any).invoice.aggregate({
68
+ where: { status: 'paid', type: 'SUBSCRIPTION' },
69
+ _sum: { amountPaid: true }
70
+ });
71
+ const topupRevenueResult = await (ctx.db as any).invoice.aggregate({
72
+ where: { status: 'paid', type: 'TOPUP' },
73
+ _sum: { amountPaid: true }
74
+ });
75
+
76
+ const subRevenue = Number(subRevenueResult._sum.amountPaid || 0) / 100;
77
+ const topupRevenue = Number(topupRevenueResult._sum.amountPaid || 0) / 100;
78
+
79
+ return {
80
+ totalUsers,
81
+ totalWorkspaces,
82
+ totalSubscriptions,
83
+ revenue: subRevenue + topupRevenue,
84
+ subscriptionRevenue: subRevenue,
85
+ topupRevenue: topupRevenue,
86
+ };
87
+ }),
88
+
89
+ listUsers: adminProcedure
90
+ .input(listUsersInput)
91
+ .query(async ({ ctx, input }) => {
92
+ const search = input.search?.trim();
93
+ const baseRoleWhere = {
94
+ role: {
95
+ name: { not: 'System Admin' },
96
+ },
97
+ } as const;
98
+
99
+ const searchWhere =
100
+ search && search.length > 0
101
+ ? {
102
+ OR: [
103
+ { email: { contains: search, mode: 'insensitive' as const } },
104
+ { name: { contains: search, mode: 'insensitive' as const } },
105
+ { id: { contains: search } },
106
+ ],
107
+ }
108
+ : undefined;
109
+
110
+ const verifiedWhere =
111
+ input.emailVerified === 'yes'
112
+ ? { emailVerified: { not: null } }
113
+ : input.emailVerified === 'no'
114
+ ? { emailVerified: null }
115
+ : undefined;
116
+
117
+ const joinedWhere =
118
+ input.joinedFrom || input.joinedTo
119
+ ? {
120
+ createdAt: {
121
+ ...(input.joinedFrom ? { gte: input.joinedFrom } : {}),
122
+ ...(input.joinedTo ? { lte: input.joinedTo } : {}),
123
+ },
124
+ }
125
+ : undefined;
126
+
127
+ const where = {
128
+ AND: [
129
+ baseRoleWhere,
130
+ ...(searchWhere ? [searchWhere] : []),
131
+ ...(verifiedWhere ? [verifiedWhere] : []),
132
+ ...(joinedWhere ? [joinedWhere] : []),
133
+ ],
134
+ };
135
+
136
+ const totalCount = await ctx.db.user.count({ where });
137
+ const pageSize = input.pageSize;
138
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
139
+ const page = Math.min(Math.max(1, input.page), totalPages);
140
+ const skip = (page - 1) * pageSize;
141
+
142
+ const users = await ctx.db.user.findMany({
143
+ where,
144
+ skip,
145
+ take: pageSize,
146
+ orderBy: { createdAt: 'desc' },
147
+ include: {
148
+ role: true,
149
+ profilePicture: true,
150
+ subscriptions: {
151
+ orderBy: { createdAt: 'desc' },
152
+ take: 1,
153
+ include: {
154
+ plan: true,
155
+ },
156
+ },
157
+ },
158
+ });
159
+
160
+ return {
161
+ users: users.map((user: any) => ({
162
+ ...user,
163
+ profilePicture: user.profilePicture?.objectKey
164
+ ? `/profile-picture/${user.profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
165
+ : null,
166
+ })),
167
+ page,
168
+ pageSize,
169
+ totalCount,
170
+ totalPages,
171
+ };
172
+ }),
173
+
174
+ /**
175
+ * Paginated global invoice list for admin Invoices page (search + filters).
176
+ */
177
+ listInvoices: adminProcedure
178
+ .input(listInvoicesInput)
179
+ .query(async ({ ctx, input }) => {
180
+ const search = input.search?.trim();
181
+
182
+ const searchWhere =
183
+ search && search.length > 0
184
+ ? {
185
+ OR: [
186
+ { id: { contains: search } },
187
+ { stripeInvoiceId: { contains: search, mode: 'insensitive' as const } },
188
+ {
189
+ user: {
190
+ email: { contains: search, mode: 'insensitive' as const },
191
+ },
192
+ },
193
+ {
194
+ user: {
195
+ name: { contains: search, mode: 'insensitive' as const },
196
+ },
197
+ },
198
+ ],
199
+ }
200
+ : undefined;
201
+
202
+ const userIdWhere = input.userId?.trim()
203
+ ? { userId: input.userId.trim() }
204
+ : undefined;
205
+
206
+ const statusWhere = input.status?.trim()
207
+ ? { status: input.status.trim() }
208
+ : undefined;
209
+
210
+ const typeWhere = input.type ? { type: input.type } : undefined;
211
+
212
+ const dateWhere =
213
+ input.from || input.to
214
+ ? {
215
+ createdAt: {
216
+ ...(input.from ? { gte: input.from } : {}),
217
+ ...(input.to ? { lte: input.to } : {}),
218
+ },
219
+ }
220
+ : undefined;
221
+
222
+ const andParts = [
223
+ ...(searchWhere ? [searchWhere] : []),
224
+ ...(userIdWhere ? [userIdWhere] : []),
225
+ ...(statusWhere ? [statusWhere] : []),
226
+ ...(typeWhere ? [typeWhere] : []),
227
+ ...(dateWhere ? [dateWhere] : []),
228
+ ];
229
+ const where = andParts.length > 0 ? { AND: andParts } : {};
230
+
231
+ const totalCount = await (ctx.db as any).invoice.count({ where });
232
+ const pageSize = input.pageSize;
233
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
234
+ const page = Math.min(Math.max(1, input.page), totalPages);
235
+ const skip = (page - 1) * pageSize;
236
+
237
+ const invoices = await (ctx.db as any).invoice.findMany({
238
+ where,
239
+ skip,
240
+ take: pageSize,
241
+ orderBy: { createdAt: 'desc' },
242
+ include: {
243
+ user: {
244
+ include: {
245
+ profilePicture: true,
246
+ },
247
+ },
248
+ subscription: {
249
+ include: { plan: true },
250
+ },
251
+ },
252
+ });
253
+
254
+ const items = invoices.map((invoice: any) => ({
255
+ ...invoice,
256
+ user: invoice.user
257
+ ? {
258
+ ...invoice.user,
259
+ profilePicture: invoice.user.profilePicture?.objectKey
260
+ ? `/profile-picture/${invoice.user.profilePicture.objectKey}?t=${new Date(invoice.user.updatedAt).getTime()}`
261
+ : null,
262
+ }
263
+ : invoice.user,
264
+ }));
265
+
266
+ return {
267
+ items,
268
+ page,
269
+ pageSize,
270
+ totalCount,
271
+ totalPages,
272
+ };
273
+ }),
274
+
275
+ listWorkspaces: adminProcedure
276
+ .input(z.object({
277
+ limit: z.number().min(1).max(100).default(50),
278
+ cursor: z.string().nullish(),
279
+ }))
280
+ .query(async ({ ctx, input }) => {
281
+ const workspaces = await ctx.db.workspace.findMany({
282
+ take: input.limit + 1,
283
+ cursor: input.cursor ? { id: input.cursor } : undefined,
284
+ orderBy: { createdAt: 'desc' },
285
+ include: {
286
+ owner: {
287
+ select: {
288
+ name: true,
289
+ email: true,
290
+ }
291
+ }
292
+ }
293
+ });
294
+
295
+ let nextCursor: typeof input.cursor | undefined = undefined;
296
+ if (workspaces.length > input.limit) {
297
+ const nextItem = workspaces.pop();
298
+ nextCursor = nextItem!.id;
299
+ }
300
+
301
+ return {
302
+ workspaces,
303
+ nextCursor,
304
+ };
305
+ }),
306
+
307
+ updateUserRole: adminProcedure
308
+ .input(z.object({
309
+ userId: z.string(),
310
+ roleName: z.string(),
311
+ }))
312
+ .mutation(async ({ ctx, input }) => {
313
+ const role = await ctx.db.role.findUnique({
314
+ where: { name: input.roleName }
315
+ });
316
+
317
+ if (!role) {
318
+ throw new Error(`Role ${input.roleName} not found`);
319
+ }
320
+
321
+ const updatedUser = await ctx.db.user.update({
322
+ where: { id: input.userId },
323
+ data: { roleId: role.id },
324
+ });
325
+
326
+ logger.info(`User ${input.userId} role updated to ${input.roleName} by admin ${ctx.userId}`, 'ADMIN');
327
+ return updatedUser;
328
+ }),
329
+
330
+ listPlans: adminProcedure
331
+ .query(async ({ ctx }) => {
332
+ return ctx.db.plan.findMany({
333
+ include: {
334
+ limit: true
335
+ },
336
+ orderBy: { price: 'asc' }
337
+ });
338
+ }),
339
+
340
+ upsertPlan: adminProcedure
341
+ .input(z.object({
342
+ id: z.string().optional(),
343
+ name: z.string(),
344
+ description: z.string().optional(),
345
+ type: z.string(), // subscription or credits
346
+ price: z.number(),
347
+ stripePriceId: z.string().optional(),
348
+ interval: z.string().nullable(),
349
+ active: z.boolean().default(true),
350
+ limits: z.object({
351
+ maxStorageBytes: z.number(),
352
+ maxWorksheets: z.number(),
353
+ maxFlashcards: z.number(),
354
+ maxPodcasts: z.number().default(0),
355
+ maxStudyGuides: z.number().default(0),
356
+ })
357
+ }))
358
+ .mutation(async ({ ctx, input }) => {
359
+ let { limits, id, stripePriceId, ...planData } = input;
360
+
361
+ // Automation: Create Stripe Product and Price if stripePriceId is missing (new plan)
362
+ if (!stripePriceId && !id) {
363
+ if (!stripe) {
364
+ throw new Error("Stripe is not configured on the server");
365
+ }
366
+
367
+ try {
368
+ // 1. Create Product
369
+ const product = await stripe.products.create({
370
+ name: planData.name,
371
+ description: planData.description,
372
+ });
373
+
374
+ // 2. Create Price
375
+ const price = await stripe.prices.create({
376
+ product: product.id,
377
+ unit_amount: Math.round(planData.price * 100), // convert to cents
378
+ currency: 'usd',
379
+ recurring: planData.type === 'subscription'
380
+ ? { interval: (planData.interval as any) || 'month' }
381
+ : undefined,
382
+ });
383
+
384
+ stripePriceId = price.id;
385
+ logger.info(`Automatically created Stripe Product (${product.id}) and Price (${price.id}) for plan: ${planData.name}`, 'STRIPE');
386
+ } catch (err: any) {
387
+ logger.error(`Failed to automate Stripe creation: ${err.message}`, 'STRIPE');
388
+ throw new Error(`Stripe error: ${err.message}`);
389
+ }
390
+ }
391
+
392
+ if (id) {
393
+ // Update
394
+ const updatedPlan = await ctx.db.plan.update({
395
+ where: { id },
396
+ data: {
397
+ ...planData,
398
+ stripePriceId,
399
+ limit: {
400
+ update: {
401
+ maxStorageBytes: BigInt(limits.maxStorageBytes),
402
+ maxWorksheets: limits.maxWorksheets,
403
+ maxFlashcards: limits.maxFlashcards,
404
+ maxPodcasts: limits.maxPodcasts,
405
+ maxStudyGuides: limits.maxStudyGuides,
406
+ }
407
+ }
408
+ }
409
+ });
410
+ logger.info(`Plan ${id} updated by admin ${ctx.userId}`, 'ADMIN');
411
+ return updatedPlan;
412
+ } else {
413
+ // Create
414
+ const newPlan = await ctx.db.plan.create({
415
+ data: {
416
+ ...planData,
417
+ stripePriceId: stripePriceId || "", // Should be set by automation above
418
+ limit: {
419
+ create: {
420
+ maxStorageBytes: BigInt(limits.maxStorageBytes),
421
+ maxWorksheets: limits.maxWorksheets,
422
+ maxFlashcards: limits.maxFlashcards,
423
+ maxPodcasts: limits.maxPodcasts,
424
+ maxStudyGuides: limits.maxStudyGuides,
425
+ }
426
+ }
427
+ }
428
+ });
429
+ logger.info(`New plan ${newPlan.id} created by admin ${ctx.userId}`, 'ADMIN');
430
+ return newPlan;
431
+ }
432
+ }),
433
+
434
+ deletePlan: adminProcedure
435
+ .input(z.object({ id: z.string() }))
436
+ .mutation(async ({ ctx, input }) => {
437
+ // Check if plan has active subscriptions
438
+ const activeSubs = await ctx.db.subscription.count({
439
+ where: { planId: input.id, status: 'active' }
440
+ });
441
+
442
+ if (activeSubs > 0) {
443
+ // If it has active subs, we should just deactivate it instead of deleting
444
+ await ctx.db.plan.update({
445
+ where: { id: input.id },
446
+ data: { active: false }
447
+ });
448
+ return { success: true, message: "Plan deactivated because it has active subscribers." };
449
+ }
450
+
451
+ await ctx.db.plan.delete({ where: { id: input.id } });
452
+ logger.info(`Plan ${input.id} deleted by admin ${ctx.userId}`, 'ADMIN');
453
+ return { success: true, message: "Plan deleted." };
454
+ }),
455
+
456
+ getUserInvoices: adminProcedure
457
+ .input(
458
+ z.object({
459
+ userId: z.string(),
460
+ /** When set, return at most this many newest invoices (e.g. 5 for user detail sheet). Omit for full history. */
461
+ limit: z.number().int().min(1).max(500).optional(),
462
+ })
463
+ )
464
+ .query(async ({ ctx, input }) => {
465
+ return (ctx.db as any).invoice.findMany({
466
+ where: { userId: input.userId },
467
+ orderBy: { createdAt: 'desc' },
468
+ take: input.limit,
469
+ include: {
470
+ subscription: {
471
+ include: { plan: true }
472
+ }
473
+ }
474
+ });
475
+ }),
476
+
477
+ getUserDetailedInfo: adminProcedure
478
+ .input(z.object({ userId: z.string() }))
479
+ .query(async ({ ctx, input }) => {
480
+ const user = await ctx.db.user.findUnique({
481
+ where: { id: input.userId },
482
+ include: {
483
+ role: true,
484
+ profilePicture: true,
485
+ subscriptions: {
486
+ orderBy: { createdAt: 'desc' },
487
+ take: 1,
488
+ include: { plan: true }
489
+ },
490
+ _count: {
491
+ select: {
492
+ workspaces: true,
493
+ artifacts: true,
494
+ }
495
+ }
496
+ }
497
+ });
498
+
499
+ if (!user) throw new Error("User not found");
500
+
501
+ // Calculate total spent by this user
502
+ const totalSpentResult = await (ctx.db as any).invoice.aggregate({
503
+ where: { userId: input.userId, status: 'paid' },
504
+ _sum: { amountPaid: true }
505
+ });
506
+ const totalSpent = Number(totalSpentResult._sum.amountPaid || 0) / 100;
507
+
508
+ // Get purchased credits summary
509
+ const purchasedCredits = await ctx.db.userCredit.groupBy({
510
+ by: ['resourceType'],
511
+ where: { userId: input.userId },
512
+ _sum: { amount: true }
513
+ });
514
+
515
+ const profilePictureUrl = (user as any).profilePicture?.objectKey
516
+ ? `/profile-picture/${(user as any).profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
517
+ : null;
518
+
519
+ return {
520
+ ...user,
521
+ totalSpent,
522
+ purchasedCredits: purchasedCredits.map(c => ({
523
+ type: c.resourceType,
524
+ amount: c._sum.amount || 0
525
+ })),
526
+ profilePicture: profilePictureUrl,
527
+ };
528
+ }),
529
+
530
+ debugInvoices: adminProcedure
531
+ .query(async ({ ctx }) => {
532
+ const count = await (ctx.db as any).invoice.count();
533
+ const all = await (ctx.db as any).invoice.findMany({ take: 10 });
534
+ return { count, all };
535
+ }),
536
+
537
+ listResourcePrices: adminProcedure
538
+ .query(async ({ ctx }) => {
539
+ return ctx.db.resourcePrice.findMany({
540
+ orderBy: { resourceType: 'asc' }
541
+ });
542
+ }),
543
+
544
+ upsertResourcePrice: adminProcedure
545
+ .input(z.object({
546
+ resourceType: z.nativeEnum(ArtifactType),
547
+ priceCents: z.number().min(0),
548
+ }))
549
+ .mutation(async ({ ctx, input }) => {
550
+ const price = await ctx.db.resourcePrice.upsert({
551
+ where: { resourceType: input.resourceType },
552
+ update: { priceCents: input.priceCents },
553
+ create: {
554
+ resourceType: input.resourceType,
555
+ priceCents: input.priceCents,
556
+ },
557
+ });
558
+ logger.info(`Resource price for ${input.resourceType} updated to ${input.priceCents} by admin ${ctx.userId}`, 'ADMIN');
559
+ return price;
560
+ }),
561
+
562
+ listRecentInvoices: adminProcedure
563
+ .input(z.object({ limit: z.number().default(10) }))
564
+ .query(async ({ ctx, input }) => {
565
+ const invoices = await (ctx.db as any).invoice.findMany({
566
+ take: input.limit,
567
+ orderBy: { createdAt: 'desc' },
568
+ include: {
569
+ user: {
570
+ include: {
571
+ profilePicture: true,
572
+ }
573
+ }
574
+ }
575
+ });
576
+
577
+ return invoices.map((invoice: any) => ({
578
+ ...invoice,
579
+ user: {
580
+ ...invoice.user,
581
+ profilePicture: invoice.user.profilePicture?.objectKey
582
+ ? `/profile-picture/${invoice.user.profilePicture.objectKey}?t=${new Date(invoice.user.updatedAt).getTime()}`
583
+ : null,
584
+ }
585
+ }));
586
+ }),
587
+
588
+ activityList: adminProcedure
589
+ .input(activityListInput)
590
+ .query(async ({ ctx, input }) => {
591
+ const where = buildActivityLogWhere({
592
+ from: input.from,
593
+ to: input.to,
594
+ actorUserId: input.actorUserId,
595
+ workspaceId: input.workspaceId,
596
+ category: input.category,
597
+ status: input.status,
598
+ search: input.search,
599
+ });
600
+
601
+ const totalCount = await ctx.db.activityLog.count({ where });
602
+ const totalPages = Math.max(1, Math.ceil(totalCount / input.limit));
603
+ const page = Math.min(Math.max(1, input.page), totalPages);
604
+ const skip = (page - 1) * input.limit;
605
+
606
+ const rows = await ctx.db.activityLog.findMany({
607
+ where,
608
+ skip,
609
+ take: input.limit,
610
+ orderBy: { createdAt: 'desc' },
611
+ include: {
612
+ actor: { select: { id: true, email: true, name: true } },
613
+ workspace: { select: { id: true, title: true } },
614
+ },
615
+ });
616
+
617
+ return {
618
+ items: rows.map((r) => ({
619
+ id: r.id,
620
+ createdAt: r.createdAt,
621
+ action: r.action,
622
+ description: getActivityHumanDescription(r.trpcPath, r.action),
623
+ category: r.category,
624
+ trpcPath: r.trpcPath,
625
+ status: r.status,
626
+ durationMs: r.durationMs,
627
+ errorCode: r.errorCode,
628
+ ipAddress: r.ipAddress,
629
+ actor: r.actor,
630
+ workspace: r.workspace,
631
+ metadata: r.metadata,
632
+ })),
633
+ page,
634
+ pageSize: input.limit,
635
+ totalCount,
636
+ totalPages,
637
+ };
638
+ }),
639
+
640
+ activityExportCsv: adminProcedure
641
+ .input(
642
+ activityLogFiltersInput.extend({
643
+ maxRows: z.number().min(1).max(5000).default(2000),
644
+ })
645
+ )
646
+ .query(async ({ ctx, input }) => {
647
+ const where = buildActivityLogWhere({
648
+ from: input.from,
649
+ to: input.to,
650
+ actorUserId: input.actorUserId,
651
+ workspaceId: input.workspaceId,
652
+ category: input.category,
653
+ status: input.status,
654
+ search: input.search,
655
+ });
656
+
657
+ const rows = await ctx.db.activityLog.findMany({
658
+ where,
659
+ orderBy: { createdAt: 'desc' },
660
+ take: input.maxRows,
661
+ include: {
662
+ actor: { select: { email: true, name: true } },
663
+ workspace: { select: { title: true } },
664
+ },
665
+ });
666
+
667
+ const header = [
668
+ 'createdAt',
669
+ 'actorEmail',
670
+ 'actorName',
671
+ 'description',
672
+ 'trpcPath',
673
+ 'category',
674
+ 'status',
675
+ 'durationMs',
676
+ 'workspaceTitle',
677
+ 'errorCode',
678
+ ].join(',');
679
+
680
+ const lines = rows.map((r) =>
681
+ [
682
+ csvEscapeCell(r.createdAt.toISOString()),
683
+ csvEscapeCell(r.actor?.email),
684
+ csvEscapeCell(r.actor?.name),
685
+ csvEscapeCell(getActivityHumanDescription(r.trpcPath, r.action)),
686
+ csvEscapeCell(r.trpcPath),
687
+ csvEscapeCell(r.category),
688
+ csvEscapeCell(r.status),
689
+ csvEscapeCell(r.durationMs),
690
+ csvEscapeCell(r.workspace?.title),
691
+ csvEscapeCell(r.errorCode),
692
+ ].join(',')
693
+ );
694
+
695
+ return {
696
+ csv: [header, ...lines].join('\n'),
697
+ count: rows.length,
698
+ };
699
+ }),
700
+
701
+ /** Deletes rows older than ACTIVITY_LOG_RETENTION_DAYS (default 365). Run manually or via cron. */
702
+ activityPurgeRetention: adminProcedure.mutation(async ({ ctx }) => {
703
+ const days = getActivityRetentionDays();
704
+ const cutoff = new Date();
705
+ cutoff.setDate(cutoff.getDate() - days);
706
+ const { deleted } = await deleteActivityLogsOlderThan(ctx.db, cutoff);
707
+ logger.info(`ActivityLog retention purge: deleted ${deleted} rows older than ${days} days (cutoff ${cutoff.toISOString()})`, 'ADMIN');
708
+ return { deleted, retentionDays: days, cutoff };
709
+ }),
710
+ });