@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
@@ -1,12 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { TRPCError } from '@trpc/server';
3
- import { router, authedProcedure } from '../trpc.js';
3
+ import { router, authedProcedure, verifiedProcedure } from '../trpc.js';
4
4
  import { supabaseClient } from '../lib/storage.js';
5
- import { ArtifactType } from '@prisma/client';
5
+ import { ArtifactType } from '../lib/constants.js';
6
6
  import { aiSessionService } from '../lib/ai-session.js';
7
7
  import PusherService from '../lib/pusher.js';
8
8
  import { members } from './members.js';
9
9
  import { logger } from '../lib/logger.js';
10
+ import { getUserStorageLimit } from '../lib/subscription_service.js';
11
+ import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
12
+ import { notifyArtifactFailed, notifyArtifactReady, notifyWorkspaceDeleted, } from '../lib/notification-service.js';
10
13
  // Helper function to update and emit analysis progress
11
14
  async function updateAnalysisProgress(db, workspaceId, progress) {
12
15
  await db.workspace.update({
@@ -15,6 +18,48 @@ async function updateAnalysisProgress(db, workspaceId, progress) {
15
18
  });
16
19
  await PusherService.emitAnalysisProgress(workspaceId, progress);
17
20
  }
21
+ const PIPELINE_STEPS = ['fileUpload', 'fileAnalysis', 'studyGuide', 'flashcards'];
22
+ function buildProgressSteps(currentStep, currentStatus, config, overrides) {
23
+ const stepIndex = PIPELINE_STEPS.indexOf(currentStep);
24
+ const steps = {};
25
+ for (let i = 0; i < PIPELINE_STEPS.length; i++) {
26
+ const step = PIPELINE_STEPS[i];
27
+ let status;
28
+ if (overrides?.[step]) {
29
+ status = overrides[step];
30
+ }
31
+ else if (i < stepIndex) {
32
+ status = 'completed';
33
+ }
34
+ else if (i === stepIndex) {
35
+ status = currentStatus;
36
+ }
37
+ else {
38
+ // Future steps: check if they're configured
39
+ if (step === 'studyGuide' && !config.generateStudyGuide) {
40
+ status = 'skipped';
41
+ }
42
+ else if (step === 'flashcards' && !config.generateFlashcards) {
43
+ status = 'skipped';
44
+ }
45
+ else {
46
+ status = 'pending';
47
+ }
48
+ }
49
+ steps[step] = { order: i + 1, status };
50
+ }
51
+ return steps;
52
+ }
53
+ function buildProgress(status, filename, fileType, currentStep, currentStepStatus, config, extra) {
54
+ return {
55
+ status,
56
+ filename,
57
+ fileType,
58
+ startedAt: new Date().toISOString(),
59
+ steps: buildProgressSteps(currentStep, currentStepStatus, config, extra),
60
+ ...extra,
61
+ };
62
+ }
18
63
  // Helper function to calculate search relevance score
19
64
  function calculateRelevance(query, ...texts) {
20
65
  const queryLower = query.toLowerCase();
@@ -71,6 +116,38 @@ export const workspace = router({
71
116
  });
72
117
  return { workspaces, folders };
73
118
  }),
119
+ /**
120
+ * Fetches the entire directory tree for the user.
121
+ * Includes Folders, Workspaces (files), and Uploads (sub-files).
122
+ */
123
+ getTree: authedProcedure
124
+ .query(async ({ ctx }) => {
125
+ const userId = ctx.session.user.id;
126
+ // 1. Fetch all folders
127
+ const allFolders = await ctx.db.folder.findMany({
128
+ where: { ownerId: userId },
129
+ orderBy: { updatedAt: 'desc' },
130
+ });
131
+ // 2. Fetch all workspaces
132
+ const allWorkspaces = await ctx.db.workspace.findMany({
133
+ where: { ownerId: userId },
134
+ include: {
135
+ uploads: {
136
+ select: {
137
+ id: true,
138
+ name: true,
139
+ mimeType: true,
140
+ createdAt: true,
141
+ }
142
+ }
143
+ },
144
+ orderBy: { updatedAt: 'desc' },
145
+ });
146
+ return {
147
+ folders: allFolders,
148
+ workspaces: allWorkspaces,
149
+ };
150
+ }),
74
151
  create: authedProcedure
75
152
  .input(z.object({
76
153
  name: z.string().min(1).max(100),
@@ -98,7 +175,10 @@ export const workspace = router({
98
175
  },
99
176
  },
100
177
  });
101
- aiSessionService.initSession(ws.id, ctx.session.user.id);
178
+ await aiSessionService.initSession(ws.id, ctx.session.user.id).catch((err) => {
179
+ logger.error('Failed to init AI session on workspace creation:', err);
180
+ });
181
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
102
182
  return ws;
103
183
  }),
104
184
  createFolder: authedProcedure
@@ -116,16 +196,24 @@ export const workspace = router({
116
196
  parentId: input.parentId ?? null,
117
197
  },
118
198
  });
199
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
119
200
  return folder;
120
201
  }),
121
202
  updateFolder: authedProcedure
122
203
  .input(z.object({
123
204
  id: z.string(),
124
205
  name: z.string().min(1).max(100).optional(),
125
- color: z.string().optional(),
206
+ markerColor: z.string().nullable().optional(),
126
207
  }))
127
208
  .mutation(async ({ ctx, input }) => {
128
- const folder = await ctx.db.folder.update({ where: { id: input.id }, data: { name: input.name, color: input.color ?? '#9D00FF' } });
209
+ const folder = await ctx.db.folder.update({
210
+ where: { id: input.id },
211
+ data: {
212
+ name: input.name,
213
+ markerColor: input.markerColor
214
+ }
215
+ });
216
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
129
217
  return folder;
130
218
  }),
131
219
  deleteFolder: authedProcedure
@@ -134,6 +222,7 @@ export const workspace = router({
134
222
  }))
135
223
  .mutation(async ({ ctx, input }) => {
136
224
  const folder = await ctx.db.folder.delete({ where: { id: input.id } });
225
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
137
226
  return folder;
138
227
  }),
139
228
  get: authedProcedure
@@ -166,15 +255,111 @@ export const workspace = router({
166
255
  orderBy: { updatedAt: 'desc' },
167
256
  });
168
257
  const spaceLeft = await ctx.db.fileAsset.aggregate({
169
- where: { workspaceId: { in: workspaces.map(ws => ws.id) }, userId: ctx.session.user.id },
258
+ where: { workspaceId: { in: workspaces.map((ws) => ws.id) }, userId: ctx.session.user.id },
170
259
  _sum: { size: true },
171
260
  });
261
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
172
262
  return {
173
263
  workspaces: workspaces.length,
174
264
  folders: folders.length,
175
265
  lastUpdated: lastUpdated?.updatedAt,
176
266
  spaceUsed: spaceLeft._sum?.size ?? 0,
177
- spaceTotal: 1000000000,
267
+ spaceTotal: storageLimit,
268
+ };
269
+ }),
270
+ // Study analytics: streaks, flashcard mastery, worksheet accuracy
271
+ getStudyAnalytics: authedProcedure
272
+ .query(async ({ ctx }) => {
273
+ const userId = ctx.session.user.id;
274
+ // Gather all study activity dates
275
+ const flashcardProgress = await ctx.db.flashcardProgress.findMany({
276
+ where: { userId },
277
+ select: { lastStudiedAt: true },
278
+ });
279
+ const worksheetProgress = await ctx.db.worksheetQuestionProgress.findMany({
280
+ where: { userId },
281
+ select: { updatedAt: true, completedAt: true },
282
+ });
283
+ // Build a set of unique study days (YYYY-MM-DD)
284
+ const studyDays = new Set();
285
+ for (const fp of flashcardProgress) {
286
+ if (fp.lastStudiedAt) {
287
+ studyDays.add(fp.lastStudiedAt.toISOString().split('T')[0]);
288
+ }
289
+ }
290
+ for (const wp of worksheetProgress) {
291
+ if (wp.completedAt) {
292
+ studyDays.add(wp.completedAt.toISOString().split('T')[0]);
293
+ }
294
+ else {
295
+ studyDays.add(wp.updatedAt.toISOString().split('T')[0]);
296
+ }
297
+ }
298
+ // Calculate streak (consecutive days ending today or yesterday)
299
+ const sortedDays = [...studyDays].sort().reverse();
300
+ let streak = 0;
301
+ if (sortedDays.length > 0) {
302
+ const today = new Date();
303
+ today.setHours(0, 0, 0, 0);
304
+ const yesterday = new Date(today);
305
+ yesterday.setDate(yesterday.getDate() - 1);
306
+ const todayStr = today.toISOString().split('T')[0];
307
+ const yesterdayStr = yesterday.toISOString().split('T')[0];
308
+ // Streak only counts if the most recent study day is today or yesterday
309
+ if (sortedDays[0] === todayStr || sortedDays[0] === yesterdayStr) {
310
+ streak = 1;
311
+ for (let i = 1; i < sortedDays.length; i++) {
312
+ const current = new Date(sortedDays[i - 1]);
313
+ const prev = new Date(sortedDays[i]);
314
+ const diffDays = (current.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
315
+ if (diffDays === 1) {
316
+ streak++;
317
+ }
318
+ else {
319
+ break;
320
+ }
321
+ }
322
+ }
323
+ }
324
+ // Weekly activity (last 7 days)
325
+ const weeklyActivity = [];
326
+ const today = new Date();
327
+ today.setHours(0, 0, 0, 0);
328
+ for (let i = 6; i >= 0; i--) {
329
+ const d = new Date(today);
330
+ d.setDate(d.getDate() - i);
331
+ const dayStr = d.toISOString().split('T')[0];
332
+ weeklyActivity.push(studyDays.has(dayStr));
333
+ }
334
+ // Flashcard stats
335
+ const totalCards = await ctx.db.flashcardProgress.count({ where: { userId } });
336
+ const masteredCards = await ctx.db.flashcardProgress.count({
337
+ where: { userId, masteryLevel: { gte: 80 } },
338
+ });
339
+ const dueCards = await ctx.db.flashcardProgress.count({
340
+ where: { userId, nextReviewAt: { lte: new Date() } },
341
+ });
342
+ // Worksheet stats
343
+ const completedQuestions = await ctx.db.worksheetQuestionProgress.count({
344
+ where: { userId, completedAt: { not: null } },
345
+ });
346
+ const correctQuestions = await ctx.db.worksheetQuestionProgress.count({
347
+ where: { userId, correct: true },
348
+ });
349
+ return {
350
+ streak,
351
+ totalStudyDays: studyDays.size,
352
+ weeklyActivity,
353
+ flashcards: {
354
+ total: totalCards,
355
+ mastered: masteredCards,
356
+ dueForReview: dueCards,
357
+ },
358
+ worksheets: {
359
+ completed: completedQuestions,
360
+ correct: correctQuestions,
361
+ accuracy: completedQuestions > 0 ? Math.round((correctQuestions / completedQuestions) * 100) : 0,
362
+ },
178
363
  };
179
364
  }),
180
365
  update: authedProcedure
@@ -182,7 +367,7 @@ export const workspace = router({
182
367
  id: z.string(),
183
368
  name: z.string().min(1).max(100).optional(),
184
369
  description: z.string().max(500).optional(),
185
- color: z.string().optional(),
370
+ markerColor: z.string().nullable().optional(),
186
371
  icon: z.string().optional(),
187
372
  }))
188
373
  .mutation(async ({ ctx, input }) => {
@@ -196,10 +381,12 @@ export const workspace = router({
196
381
  data: {
197
382
  title: input.name ?? existed.title,
198
383
  description: input.description,
199
- color: input.color ?? existed.color,
384
+ // Preserve explicit null ("None color") instead of falling back.
385
+ markerColor: input.markerColor !== undefined ? input.markerColor : existed.markerColor,
200
386
  icon: input.icon ?? existed.icon,
201
387
  },
202
388
  });
389
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
203
390
  return updated;
204
391
  }),
205
392
  delete: authedProcedure
@@ -207,11 +394,37 @@ export const workspace = router({
207
394
  id: z.string(),
208
395
  }))
209
396
  .mutation(async ({ ctx, input }) => {
397
+ const workspaceToDelete = await ctx.db.workspace.findFirst({
398
+ where: { id: input.id, ownerId: ctx.session.user.id },
399
+ select: {
400
+ id: true,
401
+ title: true,
402
+ ownerId: true,
403
+ members: {
404
+ select: { userId: true },
405
+ },
406
+ },
407
+ });
408
+ if (!workspaceToDelete)
409
+ throw new TRPCError({ code: 'NOT_FOUND' });
410
+ const actor = await ctx.db.user.findUnique({
411
+ where: { id: ctx.session.user.id },
412
+ select: { name: true, email: true },
413
+ });
414
+ const actorName = actor?.name || actor?.email || 'A user';
415
+ await notifyWorkspaceDeleted(ctx.db, {
416
+ recipientUserIds: workspaceToDelete.members.map((m) => m.userId),
417
+ actorUserId: ctx.session.user.id,
418
+ actorName,
419
+ workspaceId: workspaceToDelete.id,
420
+ workspaceTitle: workspaceToDelete.title,
421
+ });
210
422
  const deleted = await ctx.db.workspace.deleteMany({
211
423
  where: { id: input.id, ownerId: ctx.session.user.id },
212
424
  });
213
425
  if (deleted.count === 0)
214
426
  throw new TRPCError({ code: 'NOT_FOUND' });
427
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
215
428
  return true;
216
429
  }),
217
430
  getFolderInformation: authedProcedure
@@ -243,9 +456,11 @@ export const workspace = router({
243
456
  if (!user || !user.email)
244
457
  throw new TRPCError({ code: 'NOT_FOUND' });
245
458
  const sharedWith = await ctx.db.workspace.findMany({ where: { members: { some: { userId: ctx.session.user.id } } } });
246
- const invitations = await ctx.db.workspaceInvitation.findMany({ where: { email: user.email, acceptedAt: null }, include: {
459
+ const invitations = await ctx.db.workspaceInvitation.findMany({
460
+ where: { email: user.email, acceptedAt: null }, include: {
247
461
  workspace: true,
248
- } });
462
+ }
463
+ });
249
464
  return { shared: sharedWith, invitations };
250
465
  }),
251
466
  uploadFiles: authedProcedure
@@ -262,6 +477,23 @@ export const workspace = router({
262
477
  const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
263
478
  if (!ws)
264
479
  throw new TRPCError({ code: 'NOT_FOUND' });
480
+ // Check storage limit
481
+ const workspaces = await ctx.db.workspace.findMany({
482
+ where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
483
+ });
484
+ const spaceUsed = await ctx.db.fileAsset.aggregate({
485
+ where: { workspaceId: { in: workspaces.map((w) => w.id) }, userId: ctx.session.user.id },
486
+ _sum: { size: true },
487
+ });
488
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
489
+ const totalSize = input.files.reduce((acc, file) => acc + file.size, 0);
490
+ if ((spaceUsed._sum?.size ?? 0) + totalSize > storageLimit) {
491
+ logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${totalSize}, Limit: ${storageLimit}`);
492
+ throw new TRPCError({
493
+ code: 'FORBIDDEN',
494
+ message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
495
+ });
496
+ }
265
497
  const results = [];
266
498
  for (const file of input.files) {
267
499
  // 1. Insert into DB
@@ -277,19 +509,19 @@ export const workspace = router({
277
509
  // 2. Generate signed URL for direct upload
278
510
  const objectKey = `${ctx.session.user.id}/${record.id}-${file.filename}`;
279
511
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
280
- .from('files')
512
+ .from('media')
281
513
  .createSignedUploadUrl(objectKey); // 5 minutes
282
514
  if (signedUrlError) {
283
515
  throw new TRPCError({
284
516
  code: 'INTERNAL_SERVER_ERROR',
285
- message: `Failed to generate upload URL: ${signedUrlError.message}`
517
+ message: `Failed to upload file`
286
518
  });
287
519
  }
288
520
  // 3. Update record with bucket info
289
521
  await ctx.db.fileAsset.update({
290
522
  where: { id: record.id },
291
523
  data: {
292
- bucket: 'files',
524
+ bucket: 'media',
293
525
  objectKey: objectKey,
294
526
  },
295
527
  });
@@ -321,7 +553,7 @@ export const workspace = router({
321
553
  .from(file.bucket)
322
554
  .remove([file.objectKey])
323
555
  .catch((err) => {
324
- console.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
556
+ logger.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
325
557
  });
326
558
  }
327
559
  }
@@ -342,6 +574,22 @@ export const workspace = router({
342
574
  size: z.number(),
343
575
  }))
344
576
  .query(async ({ ctx, input }) => {
577
+ // Check storage limit
578
+ const workspaces = await ctx.db.workspace.findMany({
579
+ where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
580
+ });
581
+ const spaceUsed = await ctx.db.fileAsset.aggregate({
582
+ where: { workspaceId: { in: workspaces.map((w) => w.id) }, userId: ctx.session.user.id },
583
+ _sum: { size: true },
584
+ });
585
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
586
+ if ((spaceUsed._sum?.size ?? 0) + input.size > storageLimit) {
587
+ logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${input.size}, Limit: ${storageLimit}`);
588
+ throw new TRPCError({
589
+ code: 'FORBIDDEN',
590
+ message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
591
+ });
592
+ }
345
593
  const objectKey = `workspace_${ctx.session.user.id}/${input.workspaceId}-file_${input.filename}`;
346
594
  const fileAsset = await ctx.db.fileAsset.create({
347
595
  data: {
@@ -356,9 +604,10 @@ export const workspace = router({
356
604
  });
357
605
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
358
606
  .from('media')
359
- .createSignedUploadUrl(objectKey); // 5 minutes
607
+ .createSignedUploadUrl(objectKey, { upsert: true });
360
608
  if (signedUrlError) {
361
- throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to generate upload URL: ${signedUrlError.message}` });
609
+ logger.error('Signed upload URL error:', signedUrlError);
610
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create upload URL: ${signedUrlError.message}` });
362
611
  }
363
612
  await ctx.db.workspace.update({
364
613
  where: { id: input.workspaceId },
@@ -369,7 +618,7 @@ export const workspace = router({
369
618
  uploadUrl: signedUrlData.signedUrl,
370
619
  };
371
620
  }),
372
- uploadAndAnalyzeMedia: authedProcedure
621
+ uploadAndAnalyzeMedia: verifiedProcedure
373
622
  .input(z.object({
374
623
  workspaceId: z.string(),
375
624
  files: z.array(z.object({
@@ -385,7 +634,7 @@ export const workspace = router({
385
634
  where: { id: input.workspaceId, ownerId: ctx.session.user.id }
386
635
  });
387
636
  if (!workspace) {
388
- console.error('Workspace not found', { workspaceId: input.workspaceId, userId: ctx.session.user.id });
637
+ logger.error('Workspace not found', { workspaceId: input.workspaceId, userId: ctx.session.user.id });
389
638
  throw new TRPCError({ code: 'NOT_FOUND' });
390
639
  }
391
640
  // Check if analysis is already in progress
@@ -427,43 +676,13 @@ export const workspace = router({
427
676
  where: { id: input.workspaceId },
428
677
  data: { fileBeingAnalyzed: true },
429
678
  });
430
- PusherService.emitAnalysisProgress(input.workspaceId, {
431
- status: 'starting',
432
- filename: primaryFile.name,
433
- fileType,
434
- startedAt: new Date().toISOString(),
435
- steps: {
436
- fileUpload: { order: 1, status: 'pending' },
437
- },
438
- });
679
+ const genConfig = { generateStudyGuide: input.generateStudyGuide, generateFlashcards: input.generateFlashcards };
680
+ PusherService.emitAnalysisProgress(input.workspaceId, buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig));
439
681
  try {
440
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
441
- status: 'starting',
442
- filename: primaryFile.name,
443
- fileType,
444
- startedAt: new Date().toISOString(),
445
- steps: {
446
- fileUpload: {
447
- order: 1,
448
- status: 'pending',
449
- },
450
- fileAnalysis: {
451
- order: 2,
452
- status: 'pending',
453
- },
454
- studyGuide: {
455
- order: 3,
456
- status: input.generateStudyGuide ? 'pending' : 'skipped',
457
- },
458
- flashcards: {
459
- order: 4,
460
- status: input.generateFlashcards ? 'pending' : 'skipped',
461
- },
462
- }
463
- });
682
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig));
464
683
  }
465
684
  catch (error) {
466
- console.error('Failed to update analysis progress:', error);
685
+ logger.error('Failed to update analysis progress:', error);
467
686
  await ctx.db.workspace.update({
468
687
  where: { id: input.workspaceId },
469
688
  data: { fileBeingAnalyzed: false },
@@ -471,30 +690,7 @@ export const workspace = router({
471
690
  await PusherService.emitError(input.workspaceId, `Failed to update analysis progress: ${error}`, 'file_analysis');
472
691
  throw error;
473
692
  }
474
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
475
- status: 'uploading',
476
- filename: primaryFile.name,
477
- fileType,
478
- startedAt: new Date().toISOString(),
479
- steps: {
480
- fileUpload: {
481
- order: 1,
482
- status: 'in_progress',
483
- },
484
- fileAnalysis: {
485
- order: 2,
486
- status: 'pending',
487
- },
488
- studyGuide: {
489
- order: 3,
490
- status: input.generateStudyGuide ? 'pending' : 'skipped',
491
- },
492
- flashcards: {
493
- order: 4,
494
- status: input.generateFlashcards ? 'pending' : 'skipped',
495
- },
496
- }
497
- });
693
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('uploading', primaryFile.name, fileType, 'fileUpload', 'in_progress', genConfig));
498
694
  // Process all files using the new process_file endpoint
499
695
  for (const file of files) {
500
696
  // TypeScript: We already validated bucket and objectKey exist above
@@ -511,7 +707,7 @@ export const workspace = router({
511
707
  });
512
708
  throw new TRPCError({
513
709
  code: 'INTERNAL_SERVER_ERROR',
514
- message: `Failed to generate signed URL for file ${file.name}: ${signedUrlError.message}`
710
+ message: `Failed to upload file`
515
711
  });
516
712
  }
517
713
  const fileUrl = signedUrlData.signedUrl;
@@ -539,30 +735,7 @@ export const workspace = router({
539
735
  });
540
736
  }
541
737
  }
542
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
543
- status: 'analyzing',
544
- filename: primaryFile.name,
545
- fileType,
546
- startedAt: new Date().toISOString(),
547
- steps: {
548
- fileUpload: {
549
- order: 1,
550
- status: 'completed',
551
- },
552
- fileAnalysis: {
553
- order: 2,
554
- status: 'in_progress',
555
- },
556
- studyGuide: {
557
- order: 3,
558
- status: input.generateStudyGuide ? 'pending' : 'skipped',
559
- },
560
- flashcards: {
561
- order: 4,
562
- status: input.generateFlashcards ? 'pending' : 'skipped',
563
- },
564
- }
565
- });
738
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('analyzing', primaryFile.name, fileType, 'fileAnalysis', 'in_progress', genConfig));
566
739
  try {
567
740
  // Analyze all files - use PDF analysis if any file is a PDF, otherwise use image analysis
568
741
  // const hasPDF = files.some(f => !f.mimeType.startsWith('image/'));
@@ -574,58 +747,14 @@ export const workspace = router({
574
747
  // await aiSessionService.analyseImage(input.workspaceId, ctx.session.user.id, file.id);
575
748
  // }
576
749
  // }
577
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
578
- status: 'generating_artifacts',
579
- filename: primaryFile.name,
580
- fileType,
581
- startedAt: new Date().toISOString(),
582
- steps: {
583
- fileUpload: {
584
- order: 1,
585
- status: 'completed',
586
- },
587
- fileAnalysis: {
588
- order: 2,
589
- status: 'completed',
590
- },
591
- studyGuide: {
592
- order: 3,
593
- status: input.generateStudyGuide ? 'pending' : 'skipped',
594
- },
595
- flashcards: {
596
- order: 4,
597
- status: input.generateFlashcards ? 'pending' : 'skipped',
598
- },
599
- }
600
- });
750
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_artifacts', primaryFile.name, fileType, 'studyGuide', 'pending', genConfig));
601
751
  }
602
752
  catch (error) {
603
- console.error('Failed to analyze files:', error);
604
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
605
- status: 'error',
606
- filename: primaryFile.name,
607
- fileType,
753
+ logger.error('Failed to analyze files:', error);
754
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('error', primaryFile.name, fileType, 'fileAnalysis', 'error', genConfig, {
608
755
  error: `Failed to analyze ${fileType}: ${error}`,
609
- startedAt: new Date().toISOString(),
610
- steps: {
611
- fileUpload: {
612
- order: 1,
613
- status: 'completed',
614
- },
615
- fileAnalysis: {
616
- order: 2,
617
- status: 'error',
618
- },
619
- studyGuide: {
620
- order: 3,
621
- status: 'skipped',
622
- },
623
- flashcards: {
624
- order: 4,
625
- status: 'skipped',
626
- },
627
- }
628
- });
756
+ studyGuide: 'skipped', flashcards: 'skipped',
757
+ }));
629
758
  await ctx.db.workspace.update({
630
759
  where: { id: input.workspaceId },
631
760
  data: { fileBeingAnalyzed: false },
@@ -640,164 +769,176 @@ export const workspace = router({
640
769
  worksheet: null,
641
770
  }
642
771
  };
643
- // Generate artifacts
772
+ // Ensure AI session is initialized before generating artifacts
773
+ try {
774
+ await aiSessionService.initSession(input.workspaceId, ctx.session.user.id);
775
+ }
776
+ catch (initError) {
777
+ logger.error('Failed to init AI session (continuing with workspace context):', initError);
778
+ }
779
+ // Fetch current usage and limits to enforce plan restrictions for auto-generation
780
+ const [usage, limits] = await Promise.all([
781
+ getUserUsage(ctx.session.user.id),
782
+ getUserPlanLimits(ctx.session.user.id)
783
+ ]);
784
+ // Generate artifacts - each step is isolated so failures don't block subsequent steps
644
785
  if (input.generateStudyGuide) {
645
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
646
- status: 'generating_study_guide',
647
- filename: primaryFile.name,
648
- fileType,
649
- startedAt: new Date().toISOString(),
650
- steps: {
651
- fileUpload: {
652
- order: 1,
653
- status: 'completed',
654
- },
655
- fileAnalysis: {
656
- order: 2,
657
- status: 'completed',
658
- },
659
- studyGuide: {
660
- order: 3,
661
- status: 'in_progress',
662
- },
663
- flashcards: {
664
- order: 4,
665
- status: input.generateFlashcards ? 'pending' : 'skipped',
666
- },
786
+ // Enforcement: Skip if limit reached
787
+ if (limits && usage.studyGuides >= limits.maxStudyGuides) {
788
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('skipped', primaryFile.name, fileType, 'studyGuide', 'skipped', genConfig));
789
+ await PusherService.emitError(input.workspaceId, 'Study guide skipped: Limit reached.', 'study_guide');
790
+ await notifyArtifactFailed(ctx.db, {
791
+ userId: ctx.session.user.id,
792
+ workspaceId: input.workspaceId,
793
+ artifactType: ArtifactType.STUDY_GUIDE,
794
+ message: 'Study guide was skipped because your plan limit was reached.',
795
+ }).catch(() => { });
796
+ }
797
+ else {
798
+ try {
799
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig));
800
+ const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
801
+ let artifact = await ctx.db.artifact.findFirst({
802
+ where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
803
+ });
804
+ if (!artifact) {
805
+ artifact = await ctx.db.artifact.create({
806
+ data: {
807
+ workspaceId: input.workspaceId,
808
+ type: ArtifactType.STUDY_GUIDE,
809
+ title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
810
+ createdById: ctx.session.user.id,
811
+ },
812
+ });
813
+ }
814
+ const lastVersion = await ctx.db.artifactVersion.findFirst({
815
+ where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
816
+ orderBy: { version: 'desc' },
817
+ });
818
+ await ctx.db.artifactVersion.create({
819
+ data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
820
+ });
821
+ results.artifacts.studyGuide = artifact;
822
+ await PusherService.emitStudyGuideComplete(input.workspaceId, artifact);
823
+ await notifyArtifactReady(ctx.db, {
824
+ userId: ctx.session.user.id,
825
+ workspaceId: input.workspaceId,
826
+ artifactId: artifact.id,
827
+ artifactType: ArtifactType.STUDY_GUIDE,
828
+ title: artifact.title,
829
+ }).catch(() => { });
667
830
  }
668
- });
669
- const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
670
- let artifact = await ctx.db.artifact.findFirst({
671
- where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
672
- });
673
- if (!artifact) {
674
- const fileNames = files.map(f => f.name).join(', ');
675
- artifact = await ctx.db.artifact.create({
676
- data: {
831
+ catch (sgError) {
832
+ logger.error('Study guide generation failed after retries:', sgError);
833
+ await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
834
+ await notifyArtifactFailed(ctx.db, {
835
+ userId: ctx.session.user.id,
677
836
  workspaceId: input.workspaceId,
678
- type: ArtifactType.STUDY_GUIDE,
679
- title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
680
- createdById: ctx.session.user.id,
681
- },
682
- });
837
+ artifactType: ArtifactType.STUDY_GUIDE,
838
+ message: 'Study guide generation failed. Please try regenerating later.',
839
+ }).catch(() => { });
840
+ // Continue to flashcards - don't abort the whole pipeline
841
+ }
683
842
  }
684
- const lastVersion = await ctx.db.artifactVersion.findFirst({
685
- where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
686
- orderBy: { version: 'desc' },
687
- });
688
- await ctx.db.artifactVersion.create({
689
- data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
690
- });
691
- results.artifacts.studyGuide = artifact;
692
843
  }
693
844
  if (input.generateFlashcards) {
694
- await updateAnalysisProgress(ctx.db, input.workspaceId, {
695
- status: 'generating_flashcards',
696
- filename: primaryFile.name,
697
- fileType,
698
- startedAt: new Date().toISOString(),
699
- steps: {
700
- fileUpload: {
701
- order: 1,
702
- status: 'completed',
703
- },
704
- fileAnalysis: {
705
- order: 2,
706
- status: 'completed',
707
- },
708
- studyGuide: {
709
- order: 3,
710
- status: input.generateStudyGuide ? 'completed' : 'skipped',
711
- },
712
- flashcards: {
713
- order: 4,
714
- status: 'in_progress',
715
- },
716
- }
717
- });
718
- const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
719
- const artifact = await ctx.db.artifact.create({
720
- data: {
845
+ // Enforcement: Skip if limit reached
846
+ if (limits && usage.flashcards >= limits.maxFlashcards) {
847
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('skipped', primaryFile.name, fileType, 'flashcards', 'skipped', genConfig));
848
+ await PusherService.emitError(input.workspaceId, 'Flashcards skipped: Limit reached.', 'flashcards');
849
+ await notifyArtifactFailed(ctx.db, {
850
+ userId: ctx.session.user.id,
721
851
  workspaceId: input.workspaceId,
722
- type: ArtifactType.FLASHCARD_SET,
723
- title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
724
- createdById: ctx.session.user.id,
725
- },
726
- });
727
- // Parse JSON flashcard content
728
- try {
729
- const flashcardData = content;
730
- let createdCards = 0;
731
- for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
732
- const card = flashcardData[i];
733
- const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
734
- const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
735
- await ctx.db.flashcard.create({
852
+ artifactType: ArtifactType.FLASHCARD_SET,
853
+ message: 'Flashcards were skipped because your plan limit was reached.',
854
+ }).catch(() => { });
855
+ }
856
+ else {
857
+ try {
858
+ const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
859
+ await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig, { studyGuide: sgStatus }));
860
+ const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
861
+ const artifact = await ctx.db.artifact.create({
736
862
  data: {
737
- artifactId: artifact.id,
738
- front: front,
739
- back: back,
740
- order: i,
741
- tags: ['ai-generated', 'medium'],
863
+ workspaceId: input.workspaceId,
864
+ type: ArtifactType.FLASHCARD_SET,
865
+ title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
866
+ createdById: ctx.session.user.id,
742
867
  },
743
868
  });
744
- createdCards++;
745
- }
746
- }
747
- catch (parseError) {
748
- // Fallback to text parsing if JSON fails
749
- const lines = content.split('\n').filter(line => line.trim());
750
- for (let i = 0; i < Math.min(lines.length, 10); i++) {
751
- const line = lines[i];
752
- if (line.includes(' - ')) {
753
- const [front, back] = line.split(' - ');
754
- await ctx.db.flashcard.create({
755
- data: {
756
- artifactId: artifact.id,
757
- front: front.trim(),
758
- back: back.trim(),
759
- order: i,
760
- tags: ['ai-generated', 'medium'],
761
- },
762
- });
869
+ // Parse JSON flashcard content
870
+ try {
871
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
872
+ const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
873
+ for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
874
+ const card = flashcardData[i];
875
+ const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
876
+ const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
877
+ await ctx.db.flashcard.create({
878
+ data: {
879
+ artifactId: artifact.id,
880
+ front: front,
881
+ back: back,
882
+ order: i,
883
+ tags: ['ai-generated', 'medium'],
884
+ },
885
+ });
886
+ }
887
+ }
888
+ catch (parseError) {
889
+ console.error("Failed to parse flashcard JSON or create cards in workspace router:", parseError);
890
+ // Fallback to text parsing if JSON fails
891
+ const lines = content.split('\n').filter((line) => line.trim());
892
+ for (let i = 0; i < Math.min(lines.length, 10); i++) {
893
+ const line = lines[i];
894
+ if (line.includes(' - ')) {
895
+ const [front, back] = line.split(' - ');
896
+ await ctx.db.flashcard.create({
897
+ data: {
898
+ artifactId: artifact.id,
899
+ front: front.trim(),
900
+ back: back.trim(),
901
+ order: i,
902
+ tags: ['ai-generated', 'medium'],
903
+ },
904
+ });
905
+ }
906
+ }
763
907
  }
908
+ results.artifacts.flashcards = artifact;
909
+ await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
910
+ await notifyArtifactReady(ctx.db, {
911
+ userId: ctx.session.user.id,
912
+ workspaceId: input.workspaceId,
913
+ artifactId: artifact.id,
914
+ artifactType: ArtifactType.FLASHCARD_SET,
915
+ title: artifact.title,
916
+ }).catch(() => { });
917
+ }
918
+ catch (fcError) {
919
+ logger.error('Flashcard generation failed after retries:', fcError);
920
+ await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
921
+ await notifyArtifactFailed(ctx.db, {
922
+ userId: ctx.session.user.id,
923
+ workspaceId: input.workspaceId,
924
+ artifactType: ArtifactType.FLASHCARD_SET,
925
+ message: 'Flashcard generation failed. Please try regenerating later.',
926
+ }).catch(() => { });
764
927
  }
765
928
  }
766
- results.artifacts.flashcards = artifact;
767
929
  }
768
930
  await ctx.db.workspace.update({
769
931
  where: { id: input.workspaceId },
770
932
  data: { fileBeingAnalyzed: false },
771
933
  });
772
934
  await updateAnalysisProgress(ctx.db, input.workspaceId, {
773
- status: 'completed',
774
- filename: primaryFile.name,
775
- fileType,
776
- startedAt: new Date().toISOString(),
935
+ ...buildProgress('completed', primaryFile.name, fileType, 'flashcards', 'completed', genConfig),
777
936
  completedAt: new Date().toISOString(),
778
- steps: {
779
- fileUpload: {
780
- order: 1,
781
- status: 'completed',
782
- },
783
- fileAnalysis: {
784
- order: 2,
785
- status: 'completed',
786
- },
787
- studyGuide: {
788
- order: 3,
789
- status: input.generateStudyGuide ? 'completed' : 'skipped',
790
- },
791
- flashcards: {
792
- order: 4,
793
- status: input.generateFlashcards ? 'completed' : 'skipped',
794
- },
795
- }
796
937
  });
797
938
  return results;
798
939
  }
799
940
  catch (error) {
800
- console.error('Failed to update analysis progress:', error);
941
+ logger.error('Failed to update analysis progress:', error);
801
942
  await ctx.db.workspace.update({
802
943
  where: { id: input.workspaceId },
803
944
  data: { fileBeingAnalyzed: false },
@@ -809,46 +950,45 @@ export const workspace = router({
809
950
  search: authedProcedure
810
951
  .input(z.object({
811
952
  query: z.string(),
953
+ color: z.string().optional(),
812
954
  limit: z.number().min(1).max(100).default(20),
813
955
  }))
814
956
  .query(async ({ ctx, input }) => {
815
- const { query } = input;
957
+ const { query, color } = input;
958
+ // 1. Search Workspaces
816
959
  const workspaces = await ctx.db.workspace.findMany({
817
960
  where: {
818
961
  ownerId: ctx.session.user.id,
819
- OR: [
820
- {
821
- title: {
822
- contains: query,
823
- mode: 'insensitive',
824
- },
825
- },
826
- {
827
- description: {
828
- contains: query,
829
- mode: 'insensitive',
830
- },
831
- },
832
- ],
962
+ markerColor: color || undefined,
963
+ ...(query ? {
964
+ OR: [
965
+ { title: { contains: query, mode: 'insensitive' } },
966
+ { description: { contains: query, mode: 'insensitive' } },
967
+ ],
968
+ } : {}),
833
969
  },
834
- orderBy: {
835
- updatedAt: 'desc',
970
+ orderBy: { updatedAt: 'desc' },
971
+ take: input.limit,
972
+ });
973
+ // 2. Search Folders
974
+ const folders = await ctx.db.folder.findMany({
975
+ where: {
976
+ ownerId: ctx.session.user.id,
977
+ markerColor: color || undefined,
978
+ ...(query ? {
979
+ name: { contains: query, mode: 'insensitive' },
980
+ } : {}),
836
981
  },
982
+ orderBy: { updatedAt: 'desc' },
837
983
  take: input.limit,
838
984
  });
839
- // Update analysisProgress for each workspace with search metadata
840
- const workspaceUpdates = workspaces.map(ws => ctx.db.workspace.update({
841
- where: { id: ws.id },
842
- data: {
843
- analysisProgress: {
844
- lastSearched: new Date().toISOString(),
845
- searchQuery: query,
846
- matchedIn: ws.title.toLowerCase().includes(query.toLowerCase()) ? 'title' : 'description',
847
- }
848
- }
849
- }));
850
- await Promise.all(workspaceUpdates);
851
- return workspaces;
985
+ // Combined results with type discriminator
986
+ const results = [
987
+ ...workspaces.map((w) => ({ ...w, type: 'workspace' })),
988
+ ...folders.map((f) => ({ ...f, type: 'folder', title: f.name })), // normalize name to title
989
+ ].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
990
+ .slice(0, input.limit);
991
+ return results;
852
992
  }),
853
993
  // Members sub-router
854
994
  members,