@goscribe/server 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/check-difficulty.cjs +14 -0
  2. package/check-questions.cjs +14 -0
  3. package/db-summary.cjs +22 -0
  4. package/mcq-test.cjs +36 -0
  5. package/package.json +9 -2
  6. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  7. package/prisma/schema.prisma +471 -324
  8. package/src/context.ts +4 -1
  9. package/src/lib/activity_human_description.test.ts +28 -0
  10. package/src/lib/activity_human_description.ts +239 -0
  11. package/src/lib/activity_log_service.test.ts +37 -0
  12. package/src/lib/activity_log_service.ts +353 -0
  13. package/src/lib/ai-session.ts +79 -51
  14. package/src/lib/email.ts +213 -29
  15. package/src/lib/env.ts +23 -6
  16. package/src/lib/inference.ts +2 -2
  17. package/src/lib/notification-service.test.ts +106 -0
  18. package/src/lib/notification-service.ts +677 -0
  19. package/src/lib/prisma.ts +6 -1
  20. package/src/lib/pusher.ts +86 -2
  21. package/src/lib/stripe.ts +39 -0
  22. package/src/lib/subscription_service.ts +722 -0
  23. package/src/lib/usage_service.ts +74 -0
  24. package/src/lib/worksheet-generation.test.ts +31 -0
  25. package/src/lib/worksheet-generation.ts +139 -0
  26. package/src/routers/_app.ts +9 -0
  27. package/src/routers/admin.ts +710 -0
  28. package/src/routers/annotations.ts +41 -0
  29. package/src/routers/auth.ts +338 -28
  30. package/src/routers/copilot.ts +719 -0
  31. package/src/routers/flashcards.ts +201 -68
  32. package/src/routers/members.ts +280 -80
  33. package/src/routers/notifications.ts +142 -0
  34. package/src/routers/payment.ts +448 -0
  35. package/src/routers/podcast.ts +112 -83
  36. package/src/routers/studyguide.ts +12 -0
  37. package/src/routers/worksheets.ts +289 -66
  38. package/src/routers/workspace.ts +329 -122
  39. package/src/scripts/purge-deleted-users.ts +167 -0
  40. package/src/server.ts +137 -11
  41. package/src/services/flashcard-progress.service.ts +49 -37
  42. package/src/trpc.ts +184 -5
  43. package/test-generate.js +30 -0
  44. package/test-ratio.cjs +9 -0
  45. package/zod-test.cjs +22 -0
  46. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  47. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  48. package/prisma/seed.mjs +0 -135
@@ -1,11 +1,19 @@
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, limitedProcedure } from '../trpc.js';
4
4
  import { aiSessionService } from '../lib/ai-session.js';
5
5
  import PusherService from '../lib/pusher.js';
6
+ import { notifyArtifactFailed, notifyArtifactReady } from '../lib/notification-service.js';
6
7
  import { logger } from '../lib/logger.js';
7
8
  import { ArtifactType } from '../lib/constants.js';
8
9
  import { workspaceAccessFilter } from '../lib/workspace-access.js';
10
+ import {
11
+ mergeWorksheetGenerationConfig,
12
+ normalizeWorksheetProblemForDb,
13
+ parsePresetConfig,
14
+ worksheetModeSchema,
15
+ worksheetPresetConfigPartialSchema,
16
+ } from '../lib/worksheet-generation.js';
9
17
 
10
18
  const Difficulty = {
11
19
  EASY: 'EASY',
@@ -72,10 +80,91 @@ export const worksheets = router({
72
80
  return merged;
73
81
  }),
74
82
 
83
+ listPresets: authedProcedure
84
+ .input(z.object({ workspaceId: z.string() }))
85
+ .query(async ({ ctx, input }) => {
86
+ const ws = await ctx.db.workspace.findFirst({
87
+ where: { id: input.workspaceId, ...workspaceAccessFilter(ctx.session.user.id) },
88
+ });
89
+ if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
90
+
91
+ return ctx.db.worksheetPreset.findMany({
92
+ where: {
93
+ OR: [
94
+ { isSystem: true },
95
+ {
96
+ userId: ctx.session.user.id,
97
+ OR: [{ workspaceId: input.workspaceId }, { workspaceId: null }],
98
+ },
99
+ ],
100
+ },
101
+ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
102
+ });
103
+ }),
104
+
105
+ createPreset: authedProcedure
106
+ .input(z.object({
107
+ workspaceId: z.string().optional(),
108
+ name: z.string().min(1).max(120),
109
+ config: z.record(z.string(), z.unknown()),
110
+ }))
111
+ .mutation(async ({ ctx, input }) => {
112
+ if (input.workspaceId) {
113
+ const ws = await ctx.db.workspace.findFirst({
114
+ where: { id: input.workspaceId, ...workspaceAccessFilter(ctx.session.user.id) },
115
+ });
116
+ if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
117
+ }
118
+ const config = parsePresetConfig(input.config);
119
+ return ctx.db.worksheetPreset.create({
120
+ data: {
121
+ userId: ctx.session.user.id,
122
+ workspaceId: input.workspaceId ?? null,
123
+ name: input.name,
124
+ isSystem: false,
125
+ config: config as object,
126
+ },
127
+ });
128
+ }),
129
+
130
+ updatePreset: authedProcedure
131
+ .input(z.object({
132
+ id: z.string(),
133
+ name: z.string().min(1).max(120).optional(),
134
+ config: z.record(z.string(), z.unknown()).optional(),
135
+ }).refine(d => d.name !== undefined || d.config !== undefined, { message: 'Provide name and/or config' }))
136
+ .mutation(async ({ ctx, input }) => {
137
+ const existing = await ctx.db.worksheetPreset.findFirst({
138
+ where: { id: input.id, userId: ctx.session.user.id, isSystem: false },
139
+ });
140
+ if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found or read-only' });
141
+
142
+ const data: { name?: string; config?: object } = {};
143
+ if (input.name !== undefined) data.name = input.name;
144
+ if (input.config !== undefined) data.config = parsePresetConfig(input.config) as object;
145
+
146
+ return ctx.db.worksheetPreset.update({
147
+ where: { id: input.id },
148
+ data,
149
+ });
150
+ }),
151
+
152
+ deletePreset: authedProcedure
153
+ .input(z.object({ id: z.string() }))
154
+ .mutation(async ({ ctx, input }) => {
155
+ const existing = await ctx.db.worksheetPreset.findFirst({
156
+ where: { id: input.id, userId: ctx.session.user.id, isSystem: false },
157
+ });
158
+ if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found or read-only' });
159
+
160
+ await ctx.db.worksheetPreset.delete({ where: { id: input.id } });
161
+ return true;
162
+ }),
163
+
75
164
  // Create a worksheet set
76
- create: authedProcedure
77
- .input(z.object({
78
- workspaceId: z.string(),
165
+ create: limitedProcedure
166
+ .input(z.object({
167
+ workspaceId: z.string(),
79
168
  title: z.string().min(1).max(120),
80
169
  description: z.string().optional(),
81
170
  difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(),
@@ -166,7 +255,7 @@ export const worksheets = router({
166
255
  }),
167
256
 
168
257
  // Add a question to a worksheet
169
- createWorksheetQuestion: authedProcedure
258
+ createWorksheetQuestion: limitedProcedure
170
259
  .input(z.object({
171
260
  worksheetId: z.string(),
172
261
  prompt: z.string().min(1),
@@ -333,7 +422,7 @@ export const worksheets = router({
333
422
  }))
334
423
  .mutation(async ({ ctx, input }) => {
335
424
  const { id, problems, ...updateData } = input;
336
-
425
+
337
426
  // Verify worksheet ownership
338
427
  const existingWorksheet = await ctx.db.artifact.findFirst({
339
428
  where: {
@@ -392,89 +481,223 @@ export const worksheets = router({
392
481
  }),
393
482
 
394
483
  // Generate a worksheet from a user prompt
395
- generateFromPrompt: authedProcedure
484
+ generateFromPrompt: limitedProcedure
396
485
  .input(z.object({
397
486
  workspaceId: z.string(),
398
487
  prompt: z.string().min(1),
399
488
  numQuestions: z.number().int().min(1).max(20).default(8),
400
489
  difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'),
490
+ mode: worksheetModeSchema.optional(),
491
+ presetId: z.string().optional(),
492
+ configOverride: worksheetPresetConfigPartialSchema.optional(),
401
493
  title: z.string().optional(),
402
494
  estimatedTime: z.string().optional(),
403
495
  }))
404
496
  .mutation(async ({ ctx, input }) => {
405
- const workspace = await ctx.db.workspace.findFirst({ where: { id: input.workspaceId, ownerId: ctx.session.user.id } });
497
+ const workspace = await ctx.db.workspace.findFirst({
498
+ where: { id: input.workspaceId, ...workspaceAccessFilter(ctx.session.user.id) },
499
+ });
406
500
  if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
407
501
 
502
+ let presetRow: Awaited<ReturnType<typeof ctx.db.worksheetPreset.findFirst>> = null;
503
+ if (input.presetId) {
504
+ presetRow = await ctx.db.worksheetPreset.findFirst({
505
+ where: {
506
+ id: input.presetId,
507
+ OR: [
508
+ { isSystem: true },
509
+ {
510
+ userId: ctx.session.user.id,
511
+ OR: [{ workspaceId: input.workspaceId }, { workspaceId: null }],
512
+ },
513
+ ],
514
+ },
515
+ });
516
+ if (!presetRow) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found' });
517
+ }
518
+
519
+ const presetConfig = presetRow?.config
520
+ ? parsePresetConfig(presetRow.config)
521
+ : undefined;
522
+
523
+ const resolved = mergeWorksheetGenerationConfig(
524
+ presetConfig,
525
+ input.configOverride ?? undefined,
526
+ {
527
+ numQuestions: input.numQuestions,
528
+ difficulty: input.difficulty,
529
+ mode: input.mode,
530
+ },
531
+ );
532
+
533
+ const difficultyUpper = resolved.difficulty.toUpperCase() as 'EASY' | 'MEDIUM' | 'HARD';
534
+
535
+ console.log("[DEBUG TRPC PAYLOAD] input =", input);
536
+ console.log("[DEBUG TRPC PAYLOAD] presetConfig =", presetConfig);
537
+ console.log("[DEBUG TRPC PAYLOAD] legacy merged legacy values:", { numQuestions: input.numQuestions, difficulty: input.difficulty, mode: input.mode });
538
+ console.log("[DEBUG TRPC PAYLOAD] RESOLVED =", resolved);
539
+
540
+ const worksheetConfigSnapshot = {
541
+ mode: resolved.mode,
542
+ presetId: input.presetId ?? null,
543
+ presetName: presetRow?.name ?? null,
544
+ numQuestions: resolved.numQuestions,
545
+ difficulty: resolved.difficulty,
546
+ mcqRatio: resolved.mcqRatio ?? null,
547
+ questionTypes: resolved.questionTypes ?? null,
548
+ };
549
+
550
+ await PusherService.emitWorksheetGenerationStart(input.workspaceId);
551
+
408
552
  const artifact = await ctx.db.artifact.create({
409
553
  data: {
410
554
  workspaceId: input.workspaceId,
411
555
  type: ArtifactType.WORKSHEET,
412
- title: input.title || `Worksheet - ${new Date().toLocaleString()}`,
556
+ title: input.title || (resolved.mode === 'quiz' ? `Quiz - ${new Date().toLocaleString()}` : `Worksheet - ${new Date().toLocaleString()}`),
413
557
  createdById: ctx.session.user.id,
414
- difficulty: (input.difficulty.toUpperCase()) as any,
558
+ difficulty: difficultyUpper as any,
415
559
  estimatedTime: input.estimatedTime,
416
560
  generating: true,
417
- generatingMetadata: { quantity: input.numQuestions, difficulty: input.difficulty.toLowerCase() },
561
+ generatingMetadata: {
562
+ quantity: resolved.numQuestions,
563
+ difficulty: resolved.difficulty,
564
+ mode: resolved.mode,
565
+ presetId: input.presetId ?? null,
566
+ },
567
+ worksheetConfig: worksheetConfigSnapshot as object,
418
568
  },
419
569
  });
420
- await PusherService.emitTaskComplete(input.workspaceId, 'worksheet_info', { contentLength: input.numQuestions });
421
- try {
422
-
423
- const content = await aiSessionService.generateWorksheetQuestions(input.workspaceId, ctx.session.user.id, input.numQuestions, input.difficulty as any);
424
- try {
425
- const worksheetData = JSON.parse(content);
426
- let actualWorksheetData = worksheetData;
427
- if (worksheetData.last_response) {
428
- try { actualWorksheetData = JSON.parse(worksheetData.last_response); } catch {}
429
- }
430
- const problems = actualWorksheetData.problems || actualWorksheetData.questions || actualWorksheetData || [];
431
- for (let i = 0; i < Math.min(problems.length, input.numQuestions); i++) {
432
- const problem = problems[i];
433
- const prompt = problem.question || problem.prompt || `Question ${i + 1}`;
434
- const answer = problem.answer || problem.solution || `Answer ${i + 1}`;
435
- const type = problem.type || 'TEXT';
436
- const options = problem.options || [];
437
-
438
- await ctx.db.worksheetQuestion.create({
439
- data: {
570
+
571
+ await PusherService.emitTaskComplete(input.workspaceId, 'worksheet_info', { contentLength: resolved.numQuestions });
572
+ await PusherService.emitWorksheetNew(input.workspaceId, artifact);
573
+
574
+ const userId = ctx.session.user.id;
575
+ const workspaceId = input.workspaceId;
576
+ const promptText = input.prompt;
577
+ const estTime = input.estimatedTime;
578
+
579
+ // Launch generation in the background to free up the connection pool immediately
580
+ (async () => {
581
+ try {
582
+ const content = await aiSessionService.generateWorksheetQuestions(
583
+ workspaceId,
584
+ userId,
585
+ resolved.numQuestions,
586
+ difficultyUpper,
587
+ {
588
+ mode: resolved.mode,
589
+ mcqRatio: resolved.mcqRatio,
590
+ questionTypes: resolved.questionTypes,
591
+ prompt: promptText,
592
+ },
593
+ );
594
+
595
+ let problems: any[] = [];
596
+ try {
597
+ const worksheetData = JSON.parse(content);
598
+ let actualWorksheetData: any = worksheetData;
599
+ if (worksheetData.last_response) {
600
+ try { actualWorksheetData = JSON.parse(worksheetData.last_response); } catch { /* noop */ }
601
+ }
602
+ problems = actualWorksheetData.problems || actualWorksheetData.questions || actualWorksheetData || [];
603
+ if (!Array.isArray(problems)) problems = [];
604
+ } catch (parseError) {
605
+ logger.error('Failed to parse worksheet JSON', parseError);
606
+ throw new Error('Failed to parse worksheet JSON');
607
+ }
608
+
609
+ const forceMcq = resolved.mode === 'quiz';
610
+ const questionsToCreate = [];
611
+ for (let i = 0; i < Math.min(problems.length, resolved.numQuestions); i++) {
612
+ const problem = problems[i] && typeof problems[i] === 'object' ? problems[i] as Record<string, unknown> : {};
613
+ const row = normalizeWorksheetProblemForDb(problem, i, difficultyUpper, forceMcq);
614
+ const metaPayload: Record<string, unknown> = {};
615
+ if (row.meta.options?.length) metaPayload.options = row.meta.options;
616
+ if (row.meta.markScheme != null) metaPayload.markScheme = row.meta.markScheme;
617
+
618
+ questionsToCreate.push({
440
619
  artifactId: artifact.id,
441
- prompt,
442
- answer,
443
- difficulty: (input.difficulty.toUpperCase()) as any,
444
- order: i,
445
- type,
446
- meta: {
447
- options: options.length > 0 ? options : undefined,
448
- markScheme: problem.mark_scheme || undefined,
449
- },
620
+ prompt: row.prompt,
621
+ answer: row.answer ?? '',
622
+ difficulty: row.difficulty as any,
623
+ order: row.order,
624
+ type: row.type as any,
625
+ meta: Object.keys(metaPayload).length ? (metaPayload as object) : undefined,
626
+ });
627
+ }
628
+
629
+ if (questionsToCreate.length > 0) {
630
+ await ctx.db.worksheetQuestion.createMany({
631
+ data: questionsToCreate,
632
+ });
633
+ }
634
+
635
+ let parsedTitle: string | undefined;
636
+ let parsedDescription: string | undefined;
637
+ let parsedEstimated: string | undefined;
638
+ try {
639
+ const worksheetData = JSON.parse(content);
640
+ let actualWorksheetData: any = worksheetData;
641
+ if (worksheetData.last_response) {
642
+ try { actualWorksheetData = JSON.parse(worksheetData.last_response); } catch { /* noop */ }
643
+ }
644
+ parsedTitle = typeof actualWorksheetData.title === 'string' ? actualWorksheetData.title : undefined;
645
+ parsedDescription = typeof actualWorksheetData.description === 'string' ? actualWorksheetData.description : undefined;
646
+ parsedEstimated = typeof actualWorksheetData.estimatedTime === 'string' ? actualWorksheetData.estimatedTime : undefined;
647
+ } catch { /* noop */ }
648
+
649
+ await ctx.db.artifact.update({
650
+ where: { id: artifact.id },
651
+ data: {
652
+ generating: false,
653
+ title: parsedTitle || artifact.title,
654
+ description: parsedDescription ?? undefined,
655
+ estimatedTime: estTime || parsedEstimated || undefined,
656
+ worksheetConfig: worksheetConfigSnapshot as object,
450
657
  },
451
658
  });
452
- }
453
- } catch {
454
- logger.error('Failed to parse worksheet JSON,');
455
- await ctx.db.artifact.delete({
456
- where: { id: artifact.id },
457
- });
458
- throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to parse worksheet JSON' });
459
- }
460
659
 
461
- await ctx.db.artifact.update({
462
- where: { id: artifact.id },
463
- data: { generating: false },
464
- });
660
+ const updatedWorksheet = await ctx.db.artifact.findFirst({
661
+ where: { id: artifact.id },
662
+ include: { questions: true }
663
+ });
465
664
 
466
- await PusherService.emitWorksheetComplete(input.workspaceId, artifact);
467
- } catch (error) {
468
- await ctx.db.artifact.delete({
469
- where: { id: artifact.id },
470
- });
471
- await PusherService.emitError(input.workspaceId, `Failed to generate worksheet: ${error instanceof Error ? error.message : 'Unknown error'}`, 'worksheet_generation');
472
- throw error;
473
- }
665
+ await PusherService.emitWorksheetGenerationComplete(workspaceId, updatedWorksheet);
666
+ await PusherService.emitWorksheetComplete(workspaceId, artifact);
667
+
668
+ await notifyArtifactReady(ctx.db, {
669
+ userId,
670
+ workspaceId,
671
+ artifactId: artifact.id,
672
+ artifactType: ArtifactType.WORKSHEET,
673
+ title: updatedWorksheet?.title ?? artifact.title,
674
+ }).catch(() => {});
675
+ } catch (error) {
676
+ logger.error(`Background generation failed for artifact ${artifact.id}:`, error);
677
+ await notifyArtifactFailed(ctx.db, {
678
+ userId,
679
+ workspaceId,
680
+ artifactId: artifact.id,
681
+ artifactType: ArtifactType.WORKSHEET,
682
+ title: artifact.title,
683
+ message: `Failed to generate worksheet: ${error instanceof Error ? error.message : 'Unknown error'}`,
684
+ }).catch(() => {});
685
+
686
+ await ctx.db.artifact.deleteMany({
687
+ where: { id: artifact.id },
688
+ }).catch(() => { }); // Ignore delete errors if already gone
689
+
690
+ await PusherService.emitError(
691
+ workspaceId,
692
+ `Failed to generate worksheet: ${error instanceof Error ? error.message : 'Unknown error'}`,
693
+ 'worksheet_generation'
694
+ );
695
+ }
696
+ })();
474
697
 
475
698
  return { artifact };
476
699
  }),
477
- checkAnswer: authedProcedure
700
+ checkAnswer: authedProcedure
478
701
  .input(z.object({
479
702
  worksheetId: z.string(),
480
703
  questionId: z.string(),
@@ -485,7 +708,7 @@ export const worksheets = router({
485
708
  if (!worksheet) throw new TRPCError({ code: 'NOT_FOUND' });
486
709
  const question = await ctx.db.worksheetQuestion.findFirst({ where: { id: input.questionId, artifactId: input.worksheetId } });
487
710
  if (!question) throw new TRPCError({ code: 'NOT_FOUND' });
488
-
711
+
489
712
  // Parse question meta to get mark_scheme
490
713
  const questionMeta = question.meta ? (typeof question.meta === 'object' ? question.meta : JSON.parse(question.meta as any)) : {} as any;
491
714
  const markScheme = questionMeta.markScheme;
@@ -503,11 +726,11 @@ export const worksheets = router({
503
726
  input.answer,
504
727
  markScheme
505
728
  );
506
-
729
+
507
730
  // Determine if correct by comparing achieved points vs total points
508
731
  const achievedTotal = userMarkScheme.points.reduce((sum: number, p: any) => sum + (p.achievedPoints || 0), 0);
509
732
  isCorrect = achievedTotal === markScheme.totalPoints;
510
-
733
+
511
734
  } catch (error) {
512
735
  logger.error('Failed to check answer with AI', error instanceof Error ? error.message : 'Unknown error');
513
736
  // Fallback to simple string comparison
@@ -519,7 +742,7 @@ export const worksheets = router({
519
742
  }
520
743
 
521
744
 
522
-
745
+
523
746
  // @todo: figure out this wierd fix
524
747
  const progress = await ctx.db.worksheetQuestionProgress.upsert({
525
748
  where: {
@@ -552,6 +775,6 @@ export const worksheets = router({
552
775
 
553
776
  return { isCorrect, userMarkScheme, progress };
554
777
  }),
555
- });
778
+ });
556
779
 
557
780