@goscribe/server 1.1.7 → 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 (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
@@ -1,18 +1,46 @@
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 createInferenceService from '../lib/inference.js';
5
5
  import { aiSessionService } from '../lib/ai-session.js';
6
6
  import PusherService from '../lib/pusher.js';
7
+ import { notifyArtifactFailed, notifyArtifactReady } from '../lib/notification-service.js';
7
8
  import { createFlashcardProgressService } from '../services/flashcard-progress.service.js';
8
- // Prisma enum values mapped manually to avoid type import issues in ESM
9
- const ArtifactType = {
10
- STUDY_GUIDE: 'STUDY_GUIDE',
11
- FLASHCARD_SET: 'FLASHCARD_SET',
12
- WORKSHEET: 'WORKSHEET',
13
- MEETING_SUMMARY: 'MEETING_SUMMARY',
14
- PODCAST_EPISODE: 'PODCAST_EPISODE',
15
- } as const;
9
+ import { ArtifactType } from '../lib/constants.js';
10
+ import { workspaceAccessFilter } from '../lib/workspace-access.js';
11
+ import inference from '../lib/inference.js';
12
+
13
+ const typedAnswerGradeSchema = z.object({
14
+ isCorrect: z.boolean(),
15
+ confidence: z.number().min(0).max(1),
16
+ reason: z.string().min(1),
17
+ matchedAnswer: z.string().nullable(),
18
+ });
19
+
20
+ function normalizeAcceptedAnswers(answers?: string[]): string[] {
21
+ if (!answers || answers.length === 0) return [];
22
+
23
+ const seen = new Set<string>();
24
+ const normalized: string[] = [];
25
+
26
+ for (const answer of answers) {
27
+ const trimmed = answer.trim();
28
+ if (!trimmed) continue;
29
+ const key = trimmed.toLowerCase();
30
+ if (seen.has(key)) continue;
31
+ seen.add(key);
32
+ normalized.push(trimmed);
33
+ }
34
+
35
+ return normalized;
36
+ }
37
+
38
+ function extractFirstJsonObject(text: string): string | null {
39
+ const start = text.indexOf('{');
40
+ const end = text.lastIndexOf('}');
41
+ if (start === -1 || end === -1 || end <= start) return null;
42
+ return text.slice(start, end + 1);
43
+ }
16
44
 
17
45
  export const flashcards = router({
18
46
  listSets: authedProcedure
@@ -37,7 +65,7 @@ export const flashcards = router({
37
65
  .input(z.object({ workspaceId: z.string() }))
38
66
  .query(async ({ ctx, input }) => {
39
67
  const set = await ctx.db.artifact.findFirst({
40
- where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
68
+ where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
41
69
  include: {
42
70
  flashcards: {
43
71
  include: {
@@ -50,7 +78,7 @@ export const flashcards = router({
50
78
  },
51
79
 
52
80
  },
53
- orderBy: { updatedAt: 'desc' },
81
+ orderBy: { createdAt: 'desc' },
54
82
  });
55
83
  if (!set) throw new TRPCError({ code: 'NOT_FOUND' });
56
84
  return set.flashcards;
@@ -59,23 +87,27 @@ export const flashcards = router({
59
87
  .input(z.object({ workspaceId: z.string() }))
60
88
  .query(async ({ ctx, input }) => {
61
89
  const artifact = await ctx.db.artifact.findFirst({
62
- where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } }, orderBy: { updatedAt: 'desc' },
90
+ where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
91
+ orderBy: { createdAt: 'desc' },
63
92
  });
64
93
  return artifact?.generating;
65
94
  }),
66
- createCard: authedProcedure
95
+ createCard: limitedProcedure
67
96
  .input(z.object({
68
97
  workspaceId: z.string(),
69
98
  front: z.string().min(1),
70
99
  back: z.string().min(1),
100
+ acceptedAnswers: z.array(z.string()).optional(),
71
101
  tags: z.array(z.string()).optional(),
72
102
  order: z.number().int().optional(),
73
103
  }))
74
104
  .mutation(async ({ ctx, input }) => {
75
105
  const set = await ctx.db.artifact.findFirst({
76
- where: { type: ArtifactType.FLASHCARD_SET, workspace: {
77
- id: input.workspaceId,
78
- } },
106
+ where: {
107
+ type: ArtifactType.FLASHCARD_SET, workspace: {
108
+ id: input.workspaceId,
109
+ }
110
+ },
79
111
  include: {
80
112
  flashcards: true,
81
113
  },
@@ -87,6 +119,7 @@ export const flashcards = router({
87
119
  artifactId: set.id,
88
120
  front: input.front,
89
121
  back: input.back,
122
+ acceptedAnswers: normalizeAcceptedAnswers(input.acceptedAnswers),
90
123
  tags: input.tags ?? [],
91
124
  order: input.order ?? 0,
92
125
  },
@@ -98,12 +131,13 @@ export const flashcards = router({
98
131
  cardId: z.string(),
99
132
  front: z.string().optional(),
100
133
  back: z.string().optional(),
134
+ acceptedAnswers: z.array(z.string()).optional(),
101
135
  tags: z.array(z.string()).optional(),
102
136
  order: z.number().int().optional(),
103
137
  }))
104
138
  .mutation(async ({ ctx, input }) => {
105
139
  const card = await ctx.db.flashcard.findFirst({
106
- where: { id: input.cardId, artifact: { type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } } },
140
+ where: { id: input.cardId, artifact: { type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) } },
107
141
  });
108
142
  if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
109
143
  return ctx.db.flashcard.update({
@@ -111,17 +145,88 @@ export const flashcards = router({
111
145
  data: {
112
146
  front: input.front ?? card.front,
113
147
  back: input.back ?? card.back,
148
+ acceptedAnswers: input.acceptedAnswers ? normalizeAcceptedAnswers(input.acceptedAnswers) : card.acceptedAnswers,
114
149
  tags: input.tags ?? card.tags,
115
150
  order: input.order ?? card.order,
116
151
  },
117
152
  });
118
153
  }),
119
154
 
155
+ gradeTypedAnswer: authedProcedure
156
+ .input(z.object({
157
+ flashcardId: z.string().cuid(),
158
+ userAnswer: z.string().min(1),
159
+ }))
160
+ .mutation(async ({ ctx, input }) => {
161
+ const flashcard = await ctx.db.flashcard.findFirst({
162
+ where: {
163
+ id: input.flashcardId,
164
+ artifact: {
165
+ type: ArtifactType.FLASHCARD_SET,
166
+ workspace: workspaceAccessFilter(ctx.session.user.id),
167
+ },
168
+ },
169
+ select: {
170
+ id: true,
171
+ front: true,
172
+ back: true,
173
+ acceptedAnswers: true,
174
+ },
175
+ });
176
+
177
+ if (!flashcard) {
178
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Flashcard not found' });
179
+ }
180
+
181
+ const acceptedAnswers = [
182
+ flashcard.back,
183
+ ...normalizeAcceptedAnswers(flashcard.acceptedAnswers),
184
+ ];
185
+
186
+ const prompt = [
187
+ 'Grade whether the student answer is semantically correct for the flashcard.',
188
+ '',
189
+ 'Return ONLY valid JSON with this exact shape:',
190
+ '{"isCorrect": boolean, "confidence": number, "reason": string, "matchedAnswer": string | null}',
191
+ '',
192
+ 'Rules:',
193
+ '- Accept synonyms, short paraphrases, and equivalent wording.',
194
+ '- Reject answers that are contradictory, unrelated, or materially incomplete.',
195
+ '- confidence must be from 0 to 1.',
196
+ '- reason must be under 160 characters.',
197
+ '- matchedAnswer must be the matched canonical/alias answer, or null if incorrect.',
198
+ '',
199
+ `Question: ${flashcard.front}`,
200
+ `Canonical answer: ${flashcard.back}`,
201
+ `Accepted aliases: ${JSON.stringify(acceptedAnswers)}`,
202
+ `Student answer: ${input.userAnswer}`,
203
+ ].join('\n');
204
+
205
+ try {
206
+ const response = await inference([{ role: 'user', content: prompt }]);
207
+ const content = response.choices?.[0]?.message?.content ?? '';
208
+ const jsonCandidate = extractFirstJsonObject(content);
209
+
210
+ if (!jsonCandidate) {
211
+ throw new Error('No JSON object found in grading response');
212
+ }
213
+
214
+ const parsed = JSON.parse(jsonCandidate);
215
+ return typedAnswerGradeSchema.parse(parsed);
216
+ } catch (error) {
217
+ throw new TRPCError({
218
+ code: 'INTERNAL_SERVER_ERROR',
219
+ message: 'Failed to grade typed answer. Please retry.',
220
+ cause: error,
221
+ });
222
+ }
223
+ }),
224
+
120
225
  deleteCard: authedProcedure
121
226
  .input(z.object({ cardId: z.string() }))
122
227
  .mutation(async ({ ctx, input }) => {
123
228
  const card = await ctx.db.flashcard.findFirst({
124
- where: { id: input.cardId, artifact: { workspace: { ownerId: ctx.session.user.id } } },
229
+ where: { id: input.cardId, artifact: { workspace: workspaceAccessFilter(ctx.session.user.id) } },
125
230
  });
126
231
  if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
127
232
  await ctx.db.flashcard.delete({ where: { id: input.cardId } });
@@ -132,14 +237,14 @@ export const flashcards = router({
132
237
  .input(z.object({ setId: z.string().uuid() }))
133
238
  .mutation(async ({ ctx, input }) => {
134
239
  const deleted = await ctx.db.artifact.deleteMany({
135
- where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
240
+ where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
136
241
  });
137
242
  if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
138
243
  return true;
139
244
  }),
140
245
 
141
246
  // Generate a flashcard set from a user prompt
142
- generateFromPrompt: authedProcedure
247
+ generateFromPrompt: limitedProcedure
143
248
  .input(z.object({
144
249
  workspaceId: z.string(),
145
250
  prompt: z.string().min(1),
@@ -170,84 +275,106 @@ export const flashcards = router({
170
275
  });
171
276
 
172
277
  try {
173
- await ctx.db.artifact.update({
174
- where: { id: flashcardCurrent?.id },
175
- data: { generating: true, generatingMetadata: { quantity: input.numCards, difficulty: input.difficulty.toLowerCase() } },
176
- });
278
+ await PusherService.emitTaskComplete(input.workspaceId, 'flash_card_info', { status: 'generating', numCards: input.numCards, difficulty: input.difficulty });
177
279
 
178
- await PusherService.emitTaskComplete(input.workspaceId, 'flash_card_info', { status: 'generating', numCards: input.numCards, difficulty: input.difficulty });
179
-
180
- const artifact = await ctx.db.artifact.create({
181
- data: {
182
- workspaceId: input.workspaceId,
183
- type: ArtifactType.FLASHCARD_SET,
184
- title: input.title || `Flashcards - ${new Date().toLocaleString()}`,
185
- createdById: ctx.session.user.id,
186
- flashcards: {
187
- create: flashcardCurrent?.flashcards.map((card) => ({
188
- front: card.front,
189
- back: card.back,
190
- })),
280
+ const artifact = await ctx.db.artifact.create({
281
+ data: {
282
+ workspaceId: input.workspaceId,
283
+ type: ArtifactType.FLASHCARD_SET,
284
+ title: input.title || `Flashcards - ${new Date().toLocaleString()}`,
285
+ createdById: ctx.session.user.id,
286
+ generating: true,
287
+ generatingMetadata: { quantity: input.numCards, difficulty: input.difficulty.toLowerCase() },
288
+ flashcards: {
289
+ create: flashcardCurrent?.flashcards.map((card) => ({
290
+ front: card.front,
291
+ back: card.back,
292
+ })),
293
+ },
191
294
  },
192
- },
193
- });
194
-
195
- const currentCards = flashcardCurrent?.flashcards.length || 0;
196
- const newCards = input.numCards - currentCards;
295
+ });
197
296
 
297
+ const currentCards = flashcardCurrent?.flashcards.length || 0;
298
+ const newCards = input.numCards - currentCards;
198
299
 
199
- // Generate
200
- const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, input.numCards, input.difficulty);
201
300
 
202
- let createdCards = 0;
203
- try {
204
- const flashcardData: any = content;
205
- for (let i = 0; i < Math.min(flashcardData.length, input.numCards); i++) {
206
- const card = flashcardData[i];
207
- const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
208
- const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
209
- await ctx.db.flashcard.create({
210
- data: {
211
- artifactId: artifact.id,
212
- front,
213
- back,
214
- order: i,
215
- tags: input.tags ?? ['ai-generated', input.difficulty],
216
- },
217
- });
218
- createdCards++;
219
- }
220
- } catch {
221
- // Fallback to text parsing if JSON fails
222
- const lines = content.split('\n').filter(line => line.trim());
223
- for (let i = 0; i < Math.min(lines.length, input.numCards); i++) {
224
- const line = lines[i];
225
- if (line.includes(' - ')) {
226
- const [front, back] = line.split(' - ');
301
+ // Generate
302
+ const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, input.numCards, input.difficulty, input.prompt);
303
+
304
+ let createdCards = 0;
305
+ try {
306
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
307
+ const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
308
+
309
+ for (let i = 0; i < Math.min(flashcardData.length, input.numCards); i++) {
310
+ const card = flashcardData[i];
311
+ const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
312
+ const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
227
313
  await ctx.db.flashcard.create({
228
314
  data: {
229
315
  artifactId: artifact.id,
230
- front: front.trim(),
231
- back: back.trim(),
316
+ front,
317
+ back,
232
318
  order: i,
233
319
  tags: input.tags ?? ['ai-generated', input.difficulty],
234
320
  },
235
321
  });
236
322
  createdCards++;
237
323
  }
324
+ } catch (error) {
325
+ console.error("Failed to parse flashcard JSON or create cards:", error);
326
+ // Fallback to text parsing if JSON fails
327
+ const lines = content.split('\n').filter(line => line.trim());
328
+ for (let i = 0; i < Math.min(lines.length, input.numCards); i++) {
329
+ const line = lines[i];
330
+ if (line.includes(' - ')) {
331
+ const [front, back] = line.split(' - ');
332
+ await ctx.db.flashcard.create({
333
+ data: {
334
+ artifactId: artifact.id,
335
+ front: front.trim(),
336
+ back: back.trim(),
337
+ order: i,
338
+ tags: input.tags ?? ['ai-generated', input.difficulty],
339
+ },
340
+ });
341
+ createdCards++;
342
+ }
343
+ }
238
344
  }
239
- }
240
345
 
241
- // Pusher complete
242
- await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
346
+ // Pusher complete
347
+ await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
348
+
349
+ // Set generating to false on the artifact
350
+ await ctx.db.artifact.update({ where: { id: artifact.id }, data: { generating: false } });
351
+
352
+ await notifyArtifactReady(ctx.db, {
353
+ userId: ctx.session.user.id,
354
+ workspaceId: input.workspaceId,
355
+ artifactId: artifact.id,
356
+ artifactType: ArtifactType.FLASHCARD_SET,
357
+ title: artifact.title,
358
+ }).catch(() => {});
243
359
 
244
- return { artifact, createdCards };
360
+ return { artifact, createdCards };
245
361
 
246
- } catch (error) {
247
- await ctx.db.artifact.update({ where: { id: flashcardCurrent?.id }, data: { generating: false } });
248
- await PusherService.emitError(input.workspaceId, `Failed to generate flashcards: ${error}`, 'flash_card_generation');
249
- throw error;
250
- }
362
+ } catch (error) {
363
+ if (flashcardCurrent?.id) {
364
+ await ctx.db.artifact.update({ where: { id: flashcardCurrent.id }, data: { generating: false } });
365
+ }
366
+ await PusherService.emitError(input.workspaceId, `Failed to generate flashcards: ${error}`, 'flash_card_generation');
367
+ await notifyArtifactFailed(ctx.db, {
368
+ userId: ctx.session.user.id,
369
+ workspaceId: input.workspaceId,
370
+ artifactType: ArtifactType.FLASHCARD_SET,
371
+ message:
372
+ error instanceof Error
373
+ ? error.message
374
+ : 'Flashcard generation failed.',
375
+ }).catch(() => {});
376
+ throw error;
377
+ }
251
378
  }),
252
379
 
253
380
  // Record study attempt