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