@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.
Files changed (126) 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/dist/context.d.ts +5 -1
  5. package/dist/lib/activity_human_description.d.ts +13 -0
  6. package/dist/lib/activity_human_description.js +221 -0
  7. package/dist/lib/activity_human_description.test.d.ts +1 -0
  8. package/dist/lib/activity_human_description.test.js +16 -0
  9. package/dist/lib/activity_log_service.d.ts +87 -0
  10. package/dist/lib/activity_log_service.js +276 -0
  11. package/dist/lib/activity_log_service.test.d.ts +1 -0
  12. package/dist/lib/activity_log_service.test.js +27 -0
  13. package/dist/lib/ai-session.d.ts +15 -2
  14. package/dist/lib/ai-session.js +147 -85
  15. package/dist/lib/constants.d.ts +13 -0
  16. package/dist/lib/constants.js +12 -0
  17. package/dist/lib/email.d.ts +11 -0
  18. package/dist/lib/email.js +193 -0
  19. package/dist/lib/env.d.ts +13 -0
  20. package/dist/lib/env.js +16 -0
  21. package/dist/lib/inference.d.ts +4 -1
  22. package/dist/lib/inference.js +3 -3
  23. package/dist/lib/logger.d.ts +4 -4
  24. package/dist/lib/logger.js +30 -8
  25. package/dist/lib/notification-service.d.ts +152 -0
  26. package/dist/lib/notification-service.js +473 -0
  27. package/dist/lib/notification-service.test.d.ts +1 -0
  28. package/dist/lib/notification-service.test.js +87 -0
  29. package/dist/lib/prisma.d.ts +2 -1
  30. package/dist/lib/prisma.js +5 -1
  31. package/dist/lib/pusher.d.ts +23 -0
  32. package/dist/lib/pusher.js +69 -5
  33. package/dist/lib/retry.d.ts +15 -0
  34. package/dist/lib/retry.js +37 -0
  35. package/dist/lib/storage.js +2 -2
  36. package/dist/lib/stripe.d.ts +9 -0
  37. package/dist/lib/stripe.js +36 -0
  38. package/dist/lib/subscription_service.d.ts +37 -0
  39. package/dist/lib/subscription_service.js +654 -0
  40. package/dist/lib/usage_service.d.ts +26 -0
  41. package/dist/lib/usage_service.js +59 -0
  42. package/dist/lib/worksheet-generation.d.ts +91 -0
  43. package/dist/lib/worksheet-generation.js +95 -0
  44. package/dist/lib/worksheet-generation.test.d.ts +1 -0
  45. package/dist/lib/worksheet-generation.test.js +20 -0
  46. package/dist/lib/workspace-access.d.ts +18 -0
  47. package/dist/lib/workspace-access.js +13 -0
  48. package/dist/routers/_app.d.ts +1349 -253
  49. package/dist/routers/_app.js +10 -0
  50. package/dist/routers/admin.d.ts +361 -0
  51. package/dist/routers/admin.js +633 -0
  52. package/dist/routers/annotations.d.ts +219 -0
  53. package/dist/routers/annotations.js +187 -0
  54. package/dist/routers/auth.d.ts +88 -7
  55. package/dist/routers/auth.js +339 -19
  56. package/dist/routers/chat.d.ts +6 -12
  57. package/dist/routers/copilot.d.ts +199 -0
  58. package/dist/routers/copilot.js +571 -0
  59. package/dist/routers/flashcards.d.ts +47 -81
  60. package/dist/routers/flashcards.js +143 -27
  61. package/dist/routers/members.d.ts +36 -7
  62. package/dist/routers/members.js +200 -19
  63. package/dist/routers/notifications.d.ts +99 -0
  64. package/dist/routers/notifications.js +127 -0
  65. package/dist/routers/payment.d.ts +89 -0
  66. package/dist/routers/payment.js +403 -0
  67. package/dist/routers/podcast.d.ts +8 -13
  68. package/dist/routers/podcast.js +54 -31
  69. package/dist/routers/studyguide.d.ts +1 -29
  70. package/dist/routers/studyguide.js +80 -71
  71. package/dist/routers/worksheets.d.ts +105 -38
  72. package/dist/routers/worksheets.js +258 -68
  73. package/dist/routers/workspace.d.ts +139 -60
  74. package/dist/routers/workspace.js +455 -315
  75. package/dist/scripts/purge-deleted-users.d.ts +1 -0
  76. package/dist/scripts/purge-deleted-users.js +149 -0
  77. package/dist/server.js +130 -10
  78. package/dist/services/flashcard-progress.service.d.ts +18 -66
  79. package/dist/services/flashcard-progress.service.js +51 -42
  80. package/dist/trpc.d.ts +20 -21
  81. package/dist/trpc.js +150 -1
  82. package/mcq-test.cjs +36 -0
  83. package/package.json +9 -2
  84. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  85. package/prisma/schema.prisma +471 -324
  86. package/src/context.ts +4 -1
  87. package/src/lib/activity_human_description.test.ts +28 -0
  88. package/src/lib/activity_human_description.ts +239 -0
  89. package/src/lib/activity_log_service.test.ts +37 -0
  90. package/src/lib/activity_log_service.ts +353 -0
  91. package/src/lib/ai-session.ts +79 -51
  92. package/src/lib/email.ts +213 -29
  93. package/src/lib/env.ts +23 -6
  94. package/src/lib/inference.ts +2 -2
  95. package/src/lib/notification-service.test.ts +106 -0
  96. package/src/lib/notification-service.ts +677 -0
  97. package/src/lib/prisma.ts +6 -1
  98. package/src/lib/pusher.ts +86 -2
  99. package/src/lib/stripe.ts +39 -0
  100. package/src/lib/subscription_service.ts +722 -0
  101. package/src/lib/usage_service.ts +74 -0
  102. package/src/lib/worksheet-generation.test.ts +31 -0
  103. package/src/lib/worksheet-generation.ts +139 -0
  104. package/src/routers/_app.ts +9 -0
  105. package/src/routers/admin.ts +710 -0
  106. package/src/routers/annotations.ts +41 -0
  107. package/src/routers/auth.ts +338 -28
  108. package/src/routers/copilot.ts +719 -0
  109. package/src/routers/flashcards.ts +201 -68
  110. package/src/routers/members.ts +280 -80
  111. package/src/routers/notifications.ts +142 -0
  112. package/src/routers/payment.ts +448 -0
  113. package/src/routers/podcast.ts +112 -83
  114. package/src/routers/studyguide.ts +12 -0
  115. package/src/routers/worksheets.ts +289 -66
  116. package/src/routers/workspace.ts +329 -122
  117. package/src/scripts/purge-deleted-users.ts +167 -0
  118. package/src/server.ts +137 -11
  119. package/src/services/flashcard-progress.service.ts +49 -37
  120. package/src/trpc.ts +184 -5
  121. package/test-generate.js +30 -0
  122. package/test-ratio.cjs +9 -0
  123. package/zod-test.cjs +22 -0
  124. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  125. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  126. package/prisma/seed.mjs +0 -135
@@ -1,13 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { TRPCError } from '@trpc/server';
3
- import { router, publicProcedure, authedProcedure } from '../trpc.js';
3
+ import { router, publicProcedure, 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
10
  import type { PrismaClient } from '@prisma/client';
11
+ import { getUserStorageLimit } from '../lib/subscription_service.js';
12
+ import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
13
+ import {
14
+ notifyArtifactFailed,
15
+ notifyArtifactReady,
16
+ notifyWorkspaceDeleted,
17
+ } from '../lib/notification-service.js';
11
18
 
12
19
  // Helper function to update and emit analysis progress
13
20
  async function updateAnalysisProgress(
@@ -145,6 +152,42 @@ export const workspace = router({
145
152
  return { workspaces, folders };
146
153
  }),
147
154
 
155
+ /**
156
+ * Fetches the entire directory tree for the user.
157
+ * Includes Folders, Workspaces (files), and Uploads (sub-files).
158
+ */
159
+ getTree: authedProcedure
160
+ .query(async ({ ctx }) => {
161
+ const userId = ctx.session.user.id;
162
+
163
+ // 1. Fetch all folders
164
+ const allFolders = await ctx.db.folder.findMany({
165
+ where: { ownerId: userId },
166
+ orderBy: { updatedAt: 'desc' },
167
+ });
168
+
169
+ // 2. Fetch all workspaces
170
+ const allWorkspaces = await ctx.db.workspace.findMany({
171
+ where: { ownerId: userId },
172
+ include: {
173
+ uploads: {
174
+ select: {
175
+ id: true,
176
+ name: true,
177
+ mimeType: true,
178
+ createdAt: true,
179
+ }
180
+ }
181
+ },
182
+ orderBy: { updatedAt: 'desc' },
183
+ });
184
+
185
+ return {
186
+ folders: allFolders,
187
+ workspaces: allWorkspaces,
188
+ };
189
+ }),
190
+
148
191
  create: authedProcedure
149
192
  .input(z.object({
150
193
  name: z.string().min(1).max(100),
@@ -173,7 +216,12 @@ export const workspace = router({
173
216
  },
174
217
  });
175
218
 
176
- aiSessionService.initSession(ws.id, ctx.session.user.id);
219
+ await aiSessionService.initSession(ws.id, ctx.session.user.id).catch((err) => {
220
+ logger.error('Failed to init AI session on workspace creation:', err);
221
+ });
222
+
223
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
224
+
177
225
  return ws;
178
226
  }),
179
227
  createFolder: authedProcedure
@@ -191,16 +239,28 @@ export const workspace = router({
191
239
  parentId: input.parentId ?? null,
192
240
  },
193
241
  });
242
+
243
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
244
+
194
245
  return folder;
195
246
  }),
196
247
  updateFolder: authedProcedure
197
248
  .input(z.object({
198
249
  id: z.string(),
199
250
  name: z.string().min(1).max(100).optional(),
200
- color: z.string().optional(),
251
+ markerColor: z.string().nullable().optional(),
201
252
  }))
202
253
  .mutation(async ({ ctx, input }) => {
203
- const folder = await ctx.db.folder.update({ where: { id: input.id }, data: { name: input.name, color: input.color ?? '#9D00FF' } });
254
+ const folder = await ctx.db.folder.update({
255
+ where: { id: input.id },
256
+ data: {
257
+ name: input.name,
258
+ markerColor: input.markerColor
259
+ }
260
+ });
261
+
262
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
263
+
204
264
  return folder;
205
265
  }),
206
266
  deleteFolder: authedProcedure
@@ -209,6 +269,9 @@ export const workspace = router({
209
269
  }))
210
270
  .mutation(async ({ ctx, input }) => {
211
271
  const folder = await ctx.db.folder.delete({ where: { id: input.id } });
272
+
273
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
274
+
212
275
  return folder;
213
276
  }),
214
277
  get: authedProcedure
@@ -241,16 +304,18 @@ export const workspace = router({
241
304
  });
242
305
 
243
306
  const spaceLeft = await ctx.db.fileAsset.aggregate({
244
- where: { workspaceId: { in: workspaces.map(ws => ws.id) }, userId: ctx.session.user.id },
307
+ where: { workspaceId: { in: workspaces.map((ws: any) => ws.id) }, userId: ctx.session.user.id },
245
308
  _sum: { size: true },
246
309
  });
247
310
 
311
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
312
+
248
313
  return {
249
314
  workspaces: workspaces.length,
250
315
  folders: folders.length,
251
316
  lastUpdated: lastUpdated?.updatedAt,
252
317
  spaceUsed: spaceLeft._sum?.size ?? 0,
253
- spaceTotal: 1000000000,
318
+ spaceTotal: storageLimit,
254
319
  };
255
320
  }),
256
321
 
@@ -364,7 +429,7 @@ export const workspace = router({
364
429
  id: z.string(),
365
430
  name: z.string().min(1).max(100).optional(),
366
431
  description: z.string().max(500).optional(),
367
- color: z.string().optional(),
432
+ markerColor: z.string().nullable().optional(),
368
433
  icon: z.string().optional(),
369
434
  }))
370
435
  .mutation(async ({ ctx, input }) => {
@@ -377,10 +442,14 @@ export const workspace = router({
377
442
  data: {
378
443
  title: input.name ?? existed.title,
379
444
  description: input.description,
380
- color: input.color ?? existed.color,
445
+ // Preserve explicit null ("None color") instead of falling back.
446
+ markerColor: input.markerColor !== undefined ? input.markerColor : existed.markerColor,
381
447
  icon: input.icon ?? existed.icon,
382
448
  },
383
449
  });
450
+
451
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
452
+
384
453
  return updated;
385
454
  }),
386
455
  delete: authedProcedure
@@ -388,10 +457,41 @@ export const workspace = router({
388
457
  id: z.string(),
389
458
  }))
390
459
  .mutation(async ({ ctx, input }) => {
460
+ const workspaceToDelete = await ctx.db.workspace.findFirst({
461
+ where: { id: input.id, ownerId: ctx.session.user.id },
462
+ select: {
463
+ id: true,
464
+ title: true,
465
+ ownerId: true,
466
+ members: {
467
+ select: { userId: true },
468
+ },
469
+ },
470
+ });
471
+
472
+ if (!workspaceToDelete) throw new TRPCError({ code: 'NOT_FOUND' });
473
+
474
+ const actor = await ctx.db.user.findUnique({
475
+ where: { id: ctx.session.user.id },
476
+ select: { name: true, email: true },
477
+ });
478
+ const actorName = actor?.name || actor?.email || 'A user';
479
+
480
+ await notifyWorkspaceDeleted(ctx.db, {
481
+ recipientUserIds: workspaceToDelete.members.map((m) => m.userId),
482
+ actorUserId: ctx.session.user.id,
483
+ actorName,
484
+ workspaceId: workspaceToDelete.id,
485
+ workspaceTitle: workspaceToDelete.title,
486
+ });
487
+
391
488
  const deleted = await ctx.db.workspace.deleteMany({
392
489
  where: { id: input.id, ownerId: ctx.session.user.id },
393
490
  });
394
491
  if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
492
+
493
+ await PusherService.emitLibraryUpdate(ctx.session.user.id);
494
+
395
495
  return true;
396
496
  }),
397
497
  getFolderInformation: authedProcedure
@@ -425,9 +525,11 @@ export const workspace = router({
425
525
  const user = await ctx.db.user.findFirst({ where: { id: ctx.session.user.id } });
426
526
  if (!user || !user.email) throw new TRPCError({ code: 'NOT_FOUND' });
427
527
  const sharedWith = await ctx.db.workspace.findMany({ where: { members: { some: { userId: ctx.session.user.id } } } });
428
- const invitations = await ctx.db.workspaceInvitation.findMany({ where: { email: user.email, acceptedAt: null }, include: {
429
- workspace: true,
430
- } });
528
+ const invitations = await ctx.db.workspaceInvitation.findMany({
529
+ where: { email: user.email, acceptedAt: null }, include: {
530
+ workspace: true,
531
+ }
532
+ });
431
533
 
432
534
  return { shared: sharedWith, invitations };
433
535
  }),
@@ -446,6 +548,24 @@ export const workspace = router({
446
548
  // ensure workspace belongs to user
447
549
  const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
448
550
  if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
551
+
552
+ // Check storage limit
553
+ const workspaces = await ctx.db.workspace.findMany({
554
+ where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
555
+ });
556
+ const spaceUsed = await ctx.db.fileAsset.aggregate({
557
+ where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
558
+ _sum: { size: true },
559
+ });
560
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
561
+ const totalSize = input.files.reduce((acc, file) => acc + file.size, 0);
562
+ if ((spaceUsed._sum?.size ?? 0) + totalSize > storageLimit) {
563
+ logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${totalSize}, Limit: ${storageLimit}`);
564
+ throw new TRPCError({
565
+ code: 'FORBIDDEN',
566
+ message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
567
+ });
568
+ }
449
569
  const results = [];
450
570
 
451
571
  for (const file of input.files) {
@@ -463,7 +583,7 @@ export const workspace = router({
463
583
  // 2. Generate signed URL for direct upload
464
584
  const objectKey = `${ctx.session.user.id}/${record.id}-${file.filename}`;
465
585
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
466
- .from('files')
586
+ .from('media')
467
587
  .createSignedUploadUrl(objectKey); // 5 minutes
468
588
 
469
589
  if (signedUrlError) {
@@ -477,7 +597,7 @@ export const workspace = router({
477
597
  await ctx.db.fileAsset.update({
478
598
  where: { id: record.id },
479
599
  data: {
480
- bucket: 'files',
600
+ bucket: 'media',
481
601
  objectKey: objectKey,
482
602
  },
483
603
  });
@@ -534,6 +654,23 @@ export const workspace = router({
534
654
  size: z.number(),
535
655
  }))
536
656
  .query(async ({ ctx, input }) => {
657
+ // Check storage limit
658
+ const workspaces = await ctx.db.workspace.findMany({
659
+ where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
660
+ });
661
+ const spaceUsed = await ctx.db.fileAsset.aggregate({
662
+ where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
663
+ _sum: { size: true },
664
+ });
665
+ const storageLimit = await getUserStorageLimit(ctx.session.user.id);
666
+ if ((spaceUsed._sum?.size ?? 0) + input.size > storageLimit) {
667
+ logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${input.size}, Limit: ${storageLimit}`);
668
+ throw new TRPCError({
669
+ code: 'FORBIDDEN',
670
+ message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
671
+ });
672
+ }
673
+
537
674
  const objectKey = `workspace_${ctx.session.user.id}/${input.workspaceId}-file_${input.filename}`;
538
675
  const fileAsset = await ctx.db.fileAsset.create({
539
676
  data: {
@@ -548,9 +685,10 @@ export const workspace = router({
548
685
  });
549
686
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
550
687
  .from('media')
551
- .createSignedUploadUrl(objectKey); // 5 minutes
688
+ .createSignedUploadUrl(objectKey, { upsert: true });
552
689
  if (signedUrlError) {
553
- throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to upload file` });
690
+ logger.error('Signed upload URL error:', signedUrlError);
691
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create upload URL: ${signedUrlError.message}` });
554
692
  }
555
693
 
556
694
  await ctx.db.workspace.update({
@@ -563,7 +701,7 @@ export const workspace = router({
563
701
  uploadUrl: signedUrlData.signedUrl,
564
702
  };
565
703
  }),
566
- uploadAndAnalyzeMedia: authedProcedure
704
+ uploadAndAnalyzeMedia: verifiedProcedure
567
705
  .input(z.object({
568
706
  workspaceId: z.string(),
569
707
  files: z.array(z.object({
@@ -686,7 +824,7 @@ export const workspace = router({
686
824
  currentFileType,
687
825
  maxPages
688
826
  );
689
-
827
+
690
828
  if (processResult.status === 'error') {
691
829
  logger.error(`Failed to process file ${file.name}:`, processResult.error);
692
830
  // Continue processing other files even if one fails
@@ -758,108 +896,179 @@ export const workspace = router({
758
896
  }
759
897
  };
760
898
 
899
+ // Ensure AI session is initialized before generating artifacts
900
+ try {
901
+ await aiSessionService.initSession(input.workspaceId, ctx.session.user.id);
902
+ } catch (initError) {
903
+ logger.error('Failed to init AI session (continuing with workspace context):', initError);
904
+ }
905
+
906
+ // Fetch current usage and limits to enforce plan restrictions for auto-generation
907
+ const [usage, limits] = await Promise.all([
908
+ getUserUsage(ctx.session.user.id),
909
+ getUserPlanLimits(ctx.session.user.id)
910
+ ]);
911
+
761
912
  // Generate artifacts - each step is isolated so failures don't block subsequent steps
762
913
  if (input.generateStudyGuide) {
763
- try {
914
+ // Enforcement: Skip if limit reached
915
+ if (limits && usage.studyGuides >= limits.maxStudyGuides) {
764
916
  await updateAnalysisProgress(ctx.db, input.workspaceId,
765
- buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig)
917
+ buildProgress('skipped', primaryFile.name, fileType, 'studyGuide', 'skipped', genConfig)
766
918
  );
919
+ await PusherService.emitError(input.workspaceId, 'Study guide skipped: Limit reached.', 'study_guide');
920
+ await notifyArtifactFailed(ctx.db, {
921
+ userId: ctx.session.user.id,
922
+ workspaceId: input.workspaceId,
923
+ artifactType: ArtifactType.STUDY_GUIDE,
924
+ message: 'Study guide was skipped because your plan limit was reached.',
925
+ }).catch(() => {});
926
+ } else {
927
+ try {
928
+ await updateAnalysisProgress(ctx.db, input.workspaceId,
929
+ buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig)
930
+ );
767
931
 
768
- const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
932
+ const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
769
933
 
770
- let artifact = await ctx.db.artifact.findFirst({
771
- where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
772
- });
773
- if (!artifact) {
774
- artifact = await ctx.db.artifact.create({
775
- data: {
776
- workspaceId: input.workspaceId,
777
- type: ArtifactType.STUDY_GUIDE,
778
- title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
779
- createdById: ctx.session.user.id,
780
- },
934
+ let artifact = await ctx.db.artifact.findFirst({
935
+ where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
781
936
  });
782
- }
937
+ if (!artifact) {
938
+ artifact = await ctx.db.artifact.create({
939
+ data: {
940
+ workspaceId: input.workspaceId,
941
+ type: ArtifactType.STUDY_GUIDE,
942
+ title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
943
+ createdById: ctx.session.user.id,
944
+ },
945
+ });
946
+ }
783
947
 
784
- const lastVersion = await ctx.db.artifactVersion.findFirst({
785
- where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
786
- orderBy: { version: 'desc' },
787
- });
948
+ const lastVersion = await ctx.db.artifactVersion.findFirst({
949
+ where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
950
+ orderBy: { version: 'desc' },
951
+ });
788
952
 
789
- await ctx.db.artifactVersion.create({
790
- data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
791
- });
953
+ await ctx.db.artifactVersion.create({
954
+ data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
955
+ });
792
956
 
793
- results.artifacts.studyGuide = artifact;
794
- } catch (sgError) {
795
- logger.error('Study guide generation failed after retries:', sgError);
796
- await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
797
- // Continue to flashcards - don't abort the whole pipeline
957
+ results.artifacts.studyGuide = artifact;
958
+ await PusherService.emitStudyGuideComplete(input.workspaceId, artifact);
959
+ await notifyArtifactReady(ctx.db, {
960
+ userId: ctx.session.user.id,
961
+ workspaceId: input.workspaceId,
962
+ artifactId: artifact.id,
963
+ artifactType: ArtifactType.STUDY_GUIDE,
964
+ title: artifact.title,
965
+ }).catch(() => {});
966
+ } catch (sgError) {
967
+ logger.error('Study guide generation failed after retries:', sgError);
968
+ await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
969
+ await notifyArtifactFailed(ctx.db, {
970
+ userId: ctx.session.user.id,
971
+ workspaceId: input.workspaceId,
972
+ artifactType: ArtifactType.STUDY_GUIDE,
973
+ message: 'Study guide generation failed. Please try regenerating later.',
974
+ }).catch(() => {});
975
+ // Continue to flashcards - don't abort the whole pipeline
976
+ }
798
977
  }
799
978
  }
800
979
 
801
980
  if (input.generateFlashcards) {
802
- try {
803
- const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
981
+ // Enforcement: Skip if limit reached
982
+ if (limits && usage.flashcards >= limits.maxFlashcards) {
804
983
  await updateAnalysisProgress(ctx.db, input.workspaceId,
805
- buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig,
806
- { studyGuide: sgStatus } as any)
984
+ buildProgress('skipped', primaryFile.name, fileType, 'flashcards', 'skipped', genConfig)
807
985
  );
986
+ await PusherService.emitError(input.workspaceId, 'Flashcards skipped: Limit reached.', 'flashcards');
987
+ await notifyArtifactFailed(ctx.db, {
988
+ userId: ctx.session.user.id,
989
+ workspaceId: input.workspaceId,
990
+ artifactType: ArtifactType.FLASHCARD_SET,
991
+ message: 'Flashcards were skipped because your plan limit was reached.',
992
+ }).catch(() => {});
993
+ } else {
994
+ try {
995
+ const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
996
+ await updateAnalysisProgress(ctx.db, input.workspaceId,
997
+ buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig,
998
+ { studyGuide: sgStatus } as any)
999
+ );
808
1000
 
809
- const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
1001
+ const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
810
1002
 
811
- const artifact = await ctx.db.artifact.create({
812
- data: {
813
- workspaceId: input.workspaceId,
814
- type: ArtifactType.FLASHCARD_SET,
815
- title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
816
- createdById: ctx.session.user.id,
817
- },
818
- });
1003
+ const artifact = await ctx.db.artifact.create({
1004
+ data: {
1005
+ workspaceId: input.workspaceId,
1006
+ type: ArtifactType.FLASHCARD_SET,
1007
+ title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
1008
+ createdById: ctx.session.user.id,
1009
+ },
1010
+ });
819
1011
 
820
- // Parse JSON flashcard content
821
- try {
822
- const flashcardData: any = content;
1012
+ // Parse JSON flashcard content
1013
+ try {
1014
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
1015
+ const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
823
1016
 
824
- for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
825
- const card = flashcardData[i];
826
- const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
827
- const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
1017
+ for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
1018
+ const card = flashcardData[i];
1019
+ const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
1020
+ const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
828
1021
 
829
- await ctx.db.flashcard.create({
830
- data: {
831
- artifactId: artifact.id,
832
- front: front,
833
- back: back,
834
- order: i,
835
- tags: ['ai-generated', 'medium'],
836
- },
837
- });
838
- }
839
- } catch (parseError) {
840
- // Fallback to text parsing if JSON fails
841
- const lines = content.split('\n').filter((line: string) => line.trim());
842
- for (let i = 0; i < Math.min(lines.length, 10); i++) {
843
- const line = lines[i];
844
- if (line.includes(' - ')) {
845
- const [front, back] = line.split(' - ');
846
1022
  await ctx.db.flashcard.create({
847
1023
  data: {
848
1024
  artifactId: artifact.id,
849
- front: front.trim(),
850
- back: back.trim(),
1025
+ front: front,
1026
+ back: back,
851
1027
  order: i,
852
1028
  tags: ['ai-generated', 'medium'],
853
1029
  },
854
1030
  });
855
1031
  }
1032
+ } catch (parseError) {
1033
+ console.error("Failed to parse flashcard JSON or create cards in workspace router:", parseError);
1034
+ // Fallback to text parsing if JSON fails
1035
+ const lines = content.split('\n').filter((line: string) => line.trim());
1036
+ for (let i = 0; i < Math.min(lines.length, 10); i++) {
1037
+ const line = lines[i];
1038
+ if (line.includes(' - ')) {
1039
+ const [front, back] = line.split(' - ');
1040
+ await ctx.db.flashcard.create({
1041
+ data: {
1042
+ artifactId: artifact.id,
1043
+ front: front.trim(),
1044
+ back: back.trim(),
1045
+ order: i,
1046
+ tags: ['ai-generated', 'medium'],
1047
+ },
1048
+ });
1049
+ }
1050
+ }
856
1051
  }
857
- }
858
1052
 
859
- results.artifacts.flashcards = artifact;
860
- } catch (fcError) {
861
- logger.error('Flashcard generation failed after retries:', fcError);
862
- await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
1053
+ results.artifacts.flashcards = artifact;
1054
+ await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
1055
+ await notifyArtifactReady(ctx.db, {
1056
+ userId: ctx.session.user.id,
1057
+ workspaceId: input.workspaceId,
1058
+ artifactId: artifact.id,
1059
+ artifactType: ArtifactType.FLASHCARD_SET,
1060
+ title: artifact.title,
1061
+ }).catch(() => {});
1062
+ } catch (fcError) {
1063
+ logger.error('Flashcard generation failed after retries:', fcError);
1064
+ await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
1065
+ await notifyArtifactFailed(ctx.db, {
1066
+ userId: ctx.session.user.id,
1067
+ workspaceId: input.workspaceId,
1068
+ artifactType: ArtifactType.FLASHCARD_SET,
1069
+ message: 'Flashcard generation failed. Please try regenerating later.',
1070
+ }).catch(() => {});
1071
+ }
863
1072
  }
864
1073
  }
865
1074
 
@@ -886,51 +1095,49 @@ export const workspace = router({
886
1095
  search: authedProcedure
887
1096
  .input(z.object({
888
1097
  query: z.string(),
1098
+ color: z.string().optional(),
889
1099
  limit: z.number().min(1).max(100).default(20),
890
1100
  }))
891
1101
  .query(async ({ ctx, input }) => {
892
- const { query } = input;
1102
+ const { query, color } = input;
1103
+
1104
+ // 1. Search Workspaces
893
1105
  const workspaces = await ctx.db.workspace.findMany({
894
1106
  where: {
895
1107
  ownerId: ctx.session.user.id,
896
- OR: [
897
- {
898
- title: {
899
- contains: query,
900
- mode: 'insensitive',
901
- },
902
- },
903
- {
904
- description: {
905
- contains: query,
906
- mode: 'insensitive',
907
- },
908
- },
909
- ],
910
- },
911
- orderBy: {
912
- updatedAt: 'desc',
1108
+ markerColor: color || undefined,
1109
+ ...(query ? {
1110
+ OR: [
1111
+ { title: { contains: query, mode: 'insensitive' } },
1112
+ { description: { contains: query, mode: 'insensitive' } },
1113
+ ],
1114
+ } : {}),
913
1115
  },
1116
+ orderBy: { updatedAt: 'desc' },
914
1117
  take: input.limit,
915
1118
  });
916
1119
 
917
- // Update analysisProgress for each workspace with search metadata
918
- const workspaceUpdates = workspaces.map(ws =>
919
- ctx.db.workspace.update({
920
- where: { id: ws.id },
921
- data: {
922
- analysisProgress: {
923
- lastSearched: new Date().toISOString(),
924
- searchQuery: query,
925
- matchedIn: ws.title.toLowerCase().includes(query.toLowerCase()) ? 'title' : 'description',
926
- }
927
- }
928
- })
929
- );
1120
+ // 2. Search Folders
1121
+ const folders = await ctx.db.folder.findMany({
1122
+ where: {
1123
+ ownerId: ctx.session.user.id,
1124
+ markerColor: color || undefined,
1125
+ ...(query ? {
1126
+ name: { contains: query, mode: 'insensitive' },
1127
+ } : {}),
1128
+ },
1129
+ orderBy: { updatedAt: 'desc' },
1130
+ take: input.limit,
1131
+ });
930
1132
 
931
- await Promise.all(workspaceUpdates);
1133
+ // Combined results with type discriminator
1134
+ const results = [
1135
+ ...workspaces.map((w: any) => ({ ...w, type: 'workspace' as const })),
1136
+ ...folders.map((f: any) => ({ ...f, type: 'folder' as const, title: f.name })), // normalize name to title
1137
+ ].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
1138
+ .slice(0, input.limit);
932
1139
 
933
- return workspaces;
1140
+ return results;
934
1141
  }),
935
1142
 
936
1143
  // Members sub-router