@goscribe/server 1.0.10 → 1.1.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 (83) hide show
  1. package/ANALYSIS_PROGRESS_SPEC.md +463 -0
  2. package/PROGRESS_QUICK_REFERENCE.md +239 -0
  3. package/dist/lib/ai-session.d.ts +20 -9
  4. package/dist/lib/ai-session.js +316 -80
  5. package/dist/lib/auth.d.ts +35 -2
  6. package/dist/lib/auth.js +88 -15
  7. package/dist/lib/env.d.ts +32 -0
  8. package/dist/lib/env.js +46 -0
  9. package/dist/lib/errors.d.ts +33 -0
  10. package/dist/lib/errors.js +78 -0
  11. package/dist/lib/inference.d.ts +4 -1
  12. package/dist/lib/inference.js +9 -11
  13. package/dist/lib/logger.d.ts +62 -0
  14. package/dist/lib/logger.js +342 -0
  15. package/dist/lib/podcast-prompts.d.ts +43 -0
  16. package/dist/lib/podcast-prompts.js +135 -0
  17. package/dist/lib/pusher.d.ts +1 -0
  18. package/dist/lib/pusher.js +14 -2
  19. package/dist/lib/storage.d.ts +3 -3
  20. package/dist/lib/storage.js +51 -47
  21. package/dist/lib/validation.d.ts +51 -0
  22. package/dist/lib/validation.js +64 -0
  23. package/dist/routers/_app.d.ts +697 -111
  24. package/dist/routers/_app.js +5 -0
  25. package/dist/routers/auth.d.ts +11 -1
  26. package/dist/routers/chat.d.ts +11 -1
  27. package/dist/routers/flashcards.d.ts +205 -6
  28. package/dist/routers/flashcards.js +144 -66
  29. package/dist/routers/members.d.ts +165 -0
  30. package/dist/routers/members.js +531 -0
  31. package/dist/routers/podcast.d.ts +78 -63
  32. package/dist/routers/podcast.js +330 -393
  33. package/dist/routers/studyguide.d.ts +11 -1
  34. package/dist/routers/worksheets.d.ts +124 -13
  35. package/dist/routers/worksheets.js +123 -50
  36. package/dist/routers/workspace.d.ts +213 -26
  37. package/dist/routers/workspace.js +303 -181
  38. package/dist/server.js +12 -4
  39. package/dist/services/flashcard-progress.service.d.ts +183 -0
  40. package/dist/services/flashcard-progress.service.js +383 -0
  41. package/dist/services/flashcard.service.d.ts +183 -0
  42. package/dist/services/flashcard.service.js +224 -0
  43. package/dist/services/podcast-segment-reorder.d.ts +0 -0
  44. package/dist/services/podcast-segment-reorder.js +107 -0
  45. package/dist/services/podcast.service.d.ts +0 -0
  46. package/dist/services/podcast.service.js +326 -0
  47. package/dist/services/worksheet.service.d.ts +0 -0
  48. package/dist/services/worksheet.service.js +295 -0
  49. package/dist/trpc.d.ts +13 -2
  50. package/dist/trpc.js +55 -6
  51. package/dist/types/index.d.ts +126 -0
  52. package/dist/types/index.js +1 -0
  53. package/package.json +3 -2
  54. package/prisma/schema.prisma +142 -4
  55. package/src/lib/ai-session.ts +356 -85
  56. package/src/lib/auth.ts +113 -19
  57. package/src/lib/env.ts +59 -0
  58. package/src/lib/errors.ts +92 -0
  59. package/src/lib/inference.ts +11 -11
  60. package/src/lib/logger.ts +405 -0
  61. package/src/lib/pusher.ts +15 -3
  62. package/src/lib/storage.ts +56 -51
  63. package/src/lib/validation.ts +75 -0
  64. package/src/routers/_app.ts +5 -0
  65. package/src/routers/chat.ts +2 -23
  66. package/src/routers/flashcards.ts +108 -24
  67. package/src/routers/members.ts +586 -0
  68. package/src/routers/podcast.ts +385 -420
  69. package/src/routers/worksheets.ts +117 -35
  70. package/src/routers/workspace.ts +328 -195
  71. package/src/server.ts +13 -4
  72. package/src/services/flashcard-progress.service.ts +541 -0
  73. package/src/trpc.ts +59 -6
  74. package/src/types/index.ts +165 -0
  75. package/AUTH_FRONTEND_SPEC.md +0 -21
  76. package/CHAT_FRONTEND_SPEC.md +0 -474
  77. package/DATABASE_SETUP.md +0 -165
  78. package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
  79. package/PODCAST_FRONTEND_SPEC.md +0 -595
  80. package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
  81. package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
  82. package/WORKSPACE_FRONTEND_SPEC.md +0 -47
  83. package/test-ai-integration.js +0 -134
package/src/server.ts CHANGED
@@ -9,6 +9,7 @@ import * as trpcExpress from '@trpc/server/adapters/express';
9
9
  import { appRouter } from './routers/_app.js';
10
10
  import { createContext } from './context.js';
11
11
  import { prisma } from './lib/prisma.js';
12
+ import { logger } from './lib/logger.js';
12
13
 
13
14
  const PORT = process.env.PORT ? Number(process.env.PORT) : 3001;
14
15
 
@@ -25,7 +26,15 @@ async function main() {
25
26
  exposedHeaders: ['Set-Cookie'],
26
27
  }));
27
28
 
28
- app.use(morgan('dev'));
29
+ // Custom morgan middleware with logger integration
30
+ app.use(morgan('combined', {
31
+ stream: {
32
+ write: (message: string) => {
33
+ logger.info(message.trim(), 'HTTP');
34
+ }
35
+ }
36
+ }));
37
+
29
38
  app.use(compression());
30
39
  app.use(express.json({ limit: '50mb' }));
31
40
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
@@ -46,12 +55,12 @@ async function main() {
46
55
  );
47
56
 
48
57
  app.listen(PORT, () => {
49
- console.log(`✅ Server ready on http://localhost:${PORT}`);
50
- console.log(`➡️ tRPC endpoint at http://localhost:${PORT}/trpc`);
58
+ logger.info(`Server ready on http://localhost:${PORT}`, 'SERVER');
59
+ logger.info(`tRPC endpoint at http://localhost:${PORT}/trpc`, 'SERVER');
51
60
  });
52
61
  }
53
62
 
54
63
  main().catch((err) => {
55
- console.error('Failed to start server', err);
64
+ logger.error('Failed to start server', 'SERVER', undefined, err);
56
65
  process.exit(1);
57
66
  });
@@ -0,0 +1,541 @@
1
+ import type { PrismaClient } from '@prisma/client';
2
+ import { NotFoundError } from '../lib/errors.js';
3
+
4
+ /**
5
+ * SM-2 Spaced Repetition Algorithm
6
+ * https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
7
+ */
8
+ export interface SM2Result {
9
+ easeFactor: number;
10
+ interval: number;
11
+ repetitions: number;
12
+ nextReviewAt: Date;
13
+ }
14
+
15
+ export class FlashcardProgressService {
16
+ constructor(private db: PrismaClient) {}
17
+
18
+ /**
19
+ * Calculate next review using SM-2 algorithm with smart scheduling
20
+ * @param quality - 0-5 rating (0=complete blackout, 5=perfect response)
21
+ * @param easeFactor - Current ease factor (default 2.5)
22
+ * @param interval - Current interval in days (default 0)
23
+ * @param repetitions - Number of consecutive correct responses (default 0)
24
+ * @param consecutiveIncorrect - Number of consecutive failures (for smart scheduling)
25
+ * @param totalIncorrect - Total incorrect count (for context)
26
+ */
27
+ calculateSM2(
28
+ quality: number,
29
+ easeFactor: number = 2.5,
30
+ interval: number = 0,
31
+ repetitions: number = 0,
32
+ consecutiveIncorrect: number = 0,
33
+ totalIncorrect: number = 0
34
+ ): SM2Result {
35
+ // If quality < 3, determine if immediate review or short delay
36
+ if (quality < 3) {
37
+ // If no consecutive failures but has some overall failures, give short delay
38
+ const shouldDelayReview = consecutiveIncorrect === 0 && totalIncorrect > 0;
39
+
40
+ const nextReviewAt = new Date();
41
+ if (shouldDelayReview) {
42
+ // Give them a few hours to let it sink in
43
+ nextReviewAt.setHours(nextReviewAt.getHours() + 4);
44
+ }
45
+ // Otherwise immediate review (consecutiveIncorrect > 0 or first failure)
46
+
47
+ return {
48
+ easeFactor: Math.max(1.3, easeFactor - 0.2),
49
+ interval: 0,
50
+ repetitions: 0,
51
+ nextReviewAt,
52
+ };
53
+ }
54
+
55
+ // Calculate new ease factor
56
+ const newEaseFactor = Math.max(
57
+ 1.3,
58
+ easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
59
+ );
60
+
61
+ // Calculate new interval based on performance history
62
+ let newInterval: number;
63
+ if (repetitions === 0) {
64
+ // First correct answer
65
+ if (consecutiveIncorrect >= 2 || totalIncorrect >= 5) {
66
+ // If they struggled a lot, start conservative
67
+ newInterval = 1; // 1 day
68
+ } else if (totalIncorrect === 0) {
69
+ // Perfect card, never failed
70
+ newInterval = 3; // 3 days (skip ahead)
71
+ } else {
72
+ // Normal case
73
+ newInterval = 1; // 1 day
74
+ }
75
+ } else if (repetitions === 1) {
76
+ newInterval = 6; // 6 days
77
+ } else {
78
+ newInterval = Math.ceil(interval * newEaseFactor);
79
+ }
80
+
81
+ // Calculate next review date
82
+ const nextReviewAt = new Date();
83
+ nextReviewAt.setDate(nextReviewAt.getDate() + newInterval);
84
+
85
+ return {
86
+ easeFactor: newEaseFactor,
87
+ interval: newInterval,
88
+ repetitions: repetitions + 1,
89
+ nextReviewAt,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Infer confidence level based on consecutive incorrect attempts
95
+ */
96
+ inferConfidence(
97
+ isCorrect: boolean,
98
+ consecutiveIncorrect: number,
99
+ timesStudied: number
100
+ ): 'easy' | 'medium' | 'hard' {
101
+ if (!isCorrect) {
102
+ // If they got it wrong, it's obviously hard
103
+ return 'hard';
104
+ }
105
+
106
+ // If they got it right but have high consecutive failures, it's still hard
107
+ if (consecutiveIncorrect >= 3) {
108
+ return 'hard';
109
+ }
110
+
111
+ if (consecutiveIncorrect >= 1) {
112
+ return 'medium';
113
+ }
114
+
115
+ // If first time or low failure history, check overall performance
116
+ if (timesStudied === 0 || timesStudied === 1) {
117
+ return 'medium'; // Default for first attempts
118
+ }
119
+
120
+ // If they've studied it multiple times with no recent failures, it's easy
121
+ return 'easy';
122
+ }
123
+
124
+ /**
125
+ * Convert confidence to SM-2 quality rating
126
+ */
127
+ confidenceToQuality(confidence: 'easy' | 'medium' | 'hard'): number {
128
+ switch (confidence) {
129
+ case 'easy':
130
+ return 5; // Perfect response
131
+ case 'medium':
132
+ return 4; // Correct after hesitation
133
+ case 'hard':
134
+ return 3; // Correct with difficulty
135
+ default:
136
+ return 4;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Record flashcard study attempt
142
+ */
143
+ async recordStudyAttempt(data: {
144
+ userId: string;
145
+ flashcardId: string;
146
+ isCorrect: boolean;
147
+ confidence?: 'easy' | 'medium' | 'hard';
148
+ timeSpentMs?: number;
149
+ }) {
150
+ const { userId, flashcardId, isCorrect, timeSpentMs } = data;
151
+
152
+ // Verify flashcard exists and user has access
153
+ const flashcard = await this.db.flashcard.findFirst({
154
+ where: {
155
+ id: flashcardId,
156
+ artifact: {
157
+ workspace: {
158
+ OR: [
159
+ { ownerId: userId },
160
+ { members: { some: { userId } } },
161
+ ],
162
+ },
163
+ },
164
+ },
165
+ });
166
+
167
+ if (!flashcard) {
168
+ throw new NotFoundError('Flashcard');
169
+ }
170
+
171
+ // Get existing progress
172
+ const existingProgress = await this.db.flashcardProgress.findUnique({
173
+ where: {
174
+ userId_flashcardId: {
175
+ userId,
176
+ flashcardId,
177
+ },
178
+ },
179
+ });
180
+
181
+ // Calculate new consecutive incorrect count
182
+ const newConsecutiveIncorrect = isCorrect
183
+ ? 0
184
+ : (existingProgress?.timesIncorrectConsecutive || 0) + 1;
185
+
186
+ // Auto-infer confidence based on performance
187
+ const inferredConfidence = this.inferConfidence(
188
+ isCorrect,
189
+ newConsecutiveIncorrect,
190
+ existingProgress?.timesStudied || 0
191
+ );
192
+
193
+ // Use provided confidence or inferred
194
+ const finalConfidence = data.confidence || inferredConfidence;
195
+
196
+ const quality = this.confidenceToQuality(finalConfidence);
197
+
198
+ // Calculate total incorrect after this attempt
199
+ const totalIncorrect = (existingProgress?.timesIncorrect || 0) + (isCorrect ? 0 : 1);
200
+
201
+ const sm2Result = this.calculateSM2(
202
+ quality,
203
+ existingProgress?.easeFactor,
204
+ existingProgress?.interval,
205
+ existingProgress?.repetitions,
206
+ newConsecutiveIncorrect,
207
+ totalIncorrect
208
+ );
209
+
210
+ // Calculate mastery level (0-100)
211
+ const totalAttempts = (existingProgress?.timesStudied || 0) + 1;
212
+ const totalCorrect = (existingProgress?.timesCorrect || 0) + (isCorrect ? 1 : 0);
213
+ const successRate = totalCorrect / totalAttempts;
214
+
215
+ // Mastery considers success rate, repetitions, and consecutive failures
216
+ const consecutivePenalty = Math.min(newConsecutiveIncorrect * 10, 30); // Max 30% penalty
217
+ const masteryLevel = Math.min(
218
+ 100,
219
+ Math.max(
220
+ 0,
221
+ Math.round(
222
+ (successRate * 70) + // 70% weight on success rate
223
+ (Math.min(sm2Result.repetitions, 10) / 10) * 30 - // 30% weight on repetitions
224
+ consecutivePenalty // Penalty for consecutive failures
225
+ )
226
+ )
227
+ );
228
+
229
+ // Upsert progress
230
+ return this.db.flashcardProgress.upsert({
231
+ where: {
232
+ userId_flashcardId: {
233
+ userId,
234
+ flashcardId,
235
+ },
236
+ },
237
+ update: {
238
+ timesStudied: { increment: 1 },
239
+ timesCorrect: isCorrect ? { increment: 1 } : undefined,
240
+ timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
241
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
242
+ easeFactor: sm2Result.easeFactor,
243
+ interval: sm2Result.interval,
244
+ repetitions: sm2Result.repetitions,
245
+ masteryLevel,
246
+ lastStudiedAt: new Date(),
247
+ nextReviewAt: sm2Result.nextReviewAt,
248
+ },
249
+ create: {
250
+ userId,
251
+ flashcardId,
252
+ timesStudied: 1,
253
+ timesCorrect: isCorrect ? 1 : 0,
254
+ timesIncorrect: isCorrect ? 0 : 1,
255
+ timesIncorrectConsecutive: newConsecutiveIncorrect,
256
+ easeFactor: sm2Result.easeFactor,
257
+ interval: sm2Result.interval,
258
+ repetitions: sm2Result.repetitions,
259
+ masteryLevel,
260
+ lastStudiedAt: new Date(),
261
+ nextReviewAt: sm2Result.nextReviewAt,
262
+ },
263
+ include: {
264
+ flashcard: true,
265
+ },
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Get user's progress on all flashcards in a set
271
+ */
272
+ async getSetProgress(userId: string, artifactId: string) {
273
+ const flashcards = await this.db.flashcard.findMany({
274
+ where: { artifactId },
275
+ }) as any[];
276
+
277
+ // Manually fetch progress for each flashcard
278
+ const flashcardsWithProgress = await Promise.all(
279
+ flashcards.map(async (card) => {
280
+ const progress = await this.db.flashcardProgress.findUnique({
281
+ where: {
282
+ userId_flashcardId: {
283
+ userId,
284
+ flashcardId: card.id,
285
+ },
286
+ },
287
+ });
288
+
289
+ return {
290
+ flashcardId: card.id,
291
+ front: card.front,
292
+ back: card.back,
293
+ progress: progress || null,
294
+ };
295
+ })
296
+ );
297
+
298
+ return flashcardsWithProgress;
299
+ }
300
+
301
+ /**
302
+ * Get flashcards due for review, non-studied flashcards, and flashcards with low mastery
303
+ */
304
+ async getDueFlashcards(userId: string, workspaceId: string) {
305
+ const now = new Date();
306
+ const LOW_MASTERY_THRESHOLD = 50; // Consider mastery < 50 as low
307
+
308
+ // Get the latest artifact in the workspace
309
+ const latestArtifact = await this.db.artifact.findFirst({
310
+ where: {
311
+ workspaceId,
312
+ type: 'FLASHCARD_SET',
313
+ },
314
+ orderBy: {
315
+ updatedAt: 'desc',
316
+ },
317
+ });
318
+
319
+ if (!latestArtifact) {
320
+ return [];
321
+ }
322
+
323
+ // Get all flashcards from the latest artifact
324
+ const allFlashcards = await this.db.flashcard.findMany({
325
+ where: {
326
+ artifactId: latestArtifact.id,
327
+ },
328
+ include: {
329
+ artifact: true,
330
+ progress: {
331
+ where: {
332
+ userId,
333
+ },
334
+ },
335
+ },
336
+ });
337
+
338
+ console.log('allFlashcards', allFlashcards.length);
339
+
340
+ const TAKE_NUMBER = (allFlashcards.length > 10) ? 10 : allFlashcards.length;
341
+
342
+ // Get progress records for flashcards that are due or have low mastery
343
+ const progressRecords = await this.db.flashcardProgress.findMany({
344
+ where: {
345
+ userId,
346
+ OR: [
347
+ {
348
+ nextReviewAt: {
349
+ lte: now,
350
+ },
351
+ },
352
+ {
353
+ masteryLevel: {
354
+ lt: LOW_MASTERY_THRESHOLD,
355
+ },
356
+ },
357
+ {
358
+ timesStudied: {
359
+ lt: 3,
360
+ },
361
+ }
362
+ ],
363
+ flashcard: {
364
+ artifactId: latestArtifact.id,
365
+ },
366
+ },
367
+ include: {
368
+ flashcard: {
369
+ include: {
370
+ artifact: true,
371
+ },
372
+ },
373
+ },
374
+ take: TAKE_NUMBER,
375
+ });
376
+
377
+ console.log('TAKE_NUMBER', TAKE_NUMBER);
378
+ console.log('TAKE_NUMBER - progressRecords.length', TAKE_NUMBER - progressRecords.length);
379
+ console.log('progressRecords', progressRecords.map((progress) => progress.flashcard.id));
380
+
381
+ // Get flashcard IDs that already have progress records
382
+ const flashcardIdsWithProgress = new Set(
383
+ progressRecords.map((progress) => progress.flashcard.id)
384
+ );
385
+
386
+ // Find flashcards without progress records (non-studied) to pad the results
387
+ const nonStudiedFlashcards = allFlashcards
388
+ .filter((flashcard) => !flashcardIdsWithProgress.has(flashcard.id))
389
+ .slice(0, TAKE_NUMBER - progressRecords.length);
390
+
391
+ // Create progress-like structures for non-studied flashcards
392
+ const progressRecordsPadding = nonStudiedFlashcards.map((flashcard) => {
393
+ const { progress, ...flashcardWithoutProgress } = flashcard;
394
+ return {
395
+ id: `temp-${flashcard.id}`,
396
+ userId,
397
+ flashcardId: flashcard.id,
398
+ timesStudied: 0,
399
+ timesCorrect: 0,
400
+ timesIncorrect: 0,
401
+ timesIncorrectConsecutive: 0,
402
+ easeFactor: 2.5,
403
+ interval: 0,
404
+ repetitions: 0,
405
+ masteryLevel: 0,
406
+ lastStudiedAt: null,
407
+ nextReviewAt: null,
408
+ flashcard: flashcardWithoutProgress,
409
+ };
410
+ });
411
+
412
+ console.log('progressRecordsPadding', progressRecordsPadding.length);
413
+ console.log('progressRecords', progressRecords.length);
414
+ const selectedCards = [...progressRecords, ...progressRecordsPadding];
415
+
416
+ // Build result array: include progress records and non-studied flashcards
417
+ const results = [];
418
+
419
+ // Add flashcards with progress (due or low mastery)
420
+ for (const progress of progressRecords) {
421
+ results.push(progress);
422
+ }
423
+
424
+ // Sort by priority: due first (by nextReviewAt), then low mastery, then non-studied
425
+ // @todo: make an actual algorithm. research
426
+ // results.sort((a, b) => {
427
+ // // Due flashcards first (nextReviewAt <= now)
428
+ // const aIsDue = a.nextReviewAt && a.nextReviewAt <= now;
429
+ // const bIsDue = b.nextReviewAt && b.nextReviewAt <= now;
430
+ // // if (aIsDue && !bIsDue) return -1;
431
+ // // if (!aIsDue && bIsDue) return 1;
432
+
433
+ // // Among due flashcards, sort by nextReviewAt
434
+ // if (aIsDue && bIsDue && a.nextReviewAt && b.nextReviewAt) {
435
+ // return a.nextReviewAt.getTime() - b.nextReviewAt.getTime();
436
+ // }
437
+
438
+ // // Then low mastery (lower mastery first)
439
+ // if (a.masteryLevel !== b.masteryLevel) {
440
+ // return a.masteryLevel - b.masteryLevel;
441
+ // }
442
+
443
+ // // Finally, non-studied (timesStudied === 0)
444
+ // if (a.timesStudied === 0 && b.timesStudied !== 0) return -1;
445
+ // if (a.timesStudied !== 0 && b.timesStudied === 0) return 1;
446
+
447
+ // return 0;
448
+ // });
449
+
450
+ return selectedCards.map((progress) => progress.flashcard);
451
+ }
452
+
453
+ /**
454
+ * Get user statistics for a flashcard set
455
+ */
456
+ async getSetStatistics(userId: string, artifactId: string) {
457
+ const progress = await this.db.flashcardProgress.findMany({
458
+ where: {
459
+ userId,
460
+ flashcard: {
461
+ artifactId,
462
+ },
463
+ },
464
+ });
465
+
466
+ const totalCards = await this.db.flashcard.count({
467
+ where: { artifactId },
468
+ });
469
+
470
+ const studiedCards = progress.length;
471
+ const masteredCards = progress.filter((p: any) => p.masteryLevel >= 80).length;
472
+ const dueForReview = progress.filter((p: any) => p.nextReviewAt && p.nextReviewAt <= new Date()).length;
473
+
474
+ const averageMastery = progress.length > 0
475
+ ? progress.reduce((sum: number, p: any) => sum + p.masteryLevel, 0) / progress.length
476
+ : 0;
477
+
478
+ const totalCorrect = progress.reduce((sum: number, p: any) => sum + p.timesCorrect, 0);
479
+ const totalAttempts = progress.reduce((sum: number, p: any) => sum + p.timesStudied, 0);
480
+ const successRate = totalAttempts > 0 ? (totalCorrect / totalAttempts) * 100 : 0;
481
+
482
+ return {
483
+ totalCards,
484
+ studiedCards,
485
+ unstudiedCards: totalCards - studiedCards,
486
+ masteredCards,
487
+ dueForReview,
488
+ averageMastery: Math.round(averageMastery),
489
+ successRate: Math.round(successRate),
490
+ totalAttempts,
491
+ totalCorrect,
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Reset progress for a flashcard
497
+ */
498
+ async resetProgress(userId: string, flashcardId: string) {
499
+ return this.db.flashcardProgress.deleteMany({
500
+ where: {
501
+ userId,
502
+ flashcardId,
503
+ },
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Bulk record study session
509
+ */
510
+ async recordStudySession(data: {
511
+ userId: string;
512
+ attempts: Array<{
513
+ flashcardId: string;
514
+ isCorrect: boolean;
515
+ confidence?: 'easy' | 'medium' | 'hard';
516
+ timeSpentMs?: number;
517
+ }>;
518
+ }) {
519
+ const { userId, attempts } = data;
520
+
521
+ // Process attempts sequentially
522
+ const results = [];
523
+ for (const attempt of attempts) {
524
+ const result = await this.recordStudyAttempt({
525
+ userId,
526
+ ...attempt,
527
+ });
528
+ results.push(result);
529
+ }
530
+
531
+ return results;
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Factory function
537
+ */
538
+ export function createFlashcardProgressService(db: PrismaClient) {
539
+ return new FlashcardProgressService(db);
540
+ }
541
+
package/src/trpc.ts CHANGED
@@ -1,11 +1,28 @@
1
1
  import { initTRPC, TRPCError } from "@trpc/server";
2
2
  import superjson from "superjson";
3
3
  import type { Context } from "./context.js";
4
+ import { logger } from "./lib/logger.js";
5
+ import { toTRPCError } from "./lib/errors.js";
4
6
 
5
7
  const t = initTRPC.context<Context>().create({
6
8
  transformer: superjson,
7
- errorFormatter({ shape }) {
8
- return shape;
9
+ errorFormatter({ shape, error }) {
10
+ // Log errors in development
11
+ if (process.env.NODE_ENV === 'development') {
12
+ logger.error('TRPC Error', 'TRPC', {
13
+ code: error.code,
14
+ message: error.message,
15
+ cause: error.cause,
16
+ });
17
+ }
18
+
19
+ return {
20
+ ...shape,
21
+ data: {
22
+ ...shape.data,
23
+ zodError: error.cause instanceof Error ? error.cause.message : null,
24
+ },
25
+ };
9
26
  },
10
27
  });
11
28
 
@@ -13,19 +30,55 @@ export const router = t.router;
13
30
  export const middleware = t.middleware;
14
31
  export const publicProcedure = t.procedure;
15
32
 
16
- /** Middleware that enforces authentication */
33
+ /**
34
+ * Logging middleware
35
+ */
36
+ const loggingMiddleware = middleware(async ({ ctx, next, path, type }) => {
37
+ const start = Date.now();
38
+ const result = await next();
39
+ const duration = Date.now() - start;
40
+
41
+ logger.info(`TRPC ${type} ${path}`, 'TRPC', {
42
+ duration: `${duration}ms`,
43
+ userId: (ctx.session as any)?.user?.id,
44
+ });
45
+
46
+ return result;
47
+ });
48
+
49
+ /**
50
+ * Middleware that enforces authentication
51
+ */
17
52
  const isAuthed = middleware(({ ctx, next }) => {
18
53
  const hasUser = Boolean((ctx.session as any)?.user?.id);
19
54
  if (!ctx.session || !hasUser) {
20
- throw new TRPCError({ code: "UNAUTHORIZED" });
55
+ throw new TRPCError({
56
+ code: "UNAUTHORIZED",
57
+ message: "You must be logged in to access this resource"
58
+ });
21
59
  }
22
60
 
23
61
  return next({
24
62
  ctx: {
25
63
  session: ctx.session,
64
+ userId: (ctx.session as any).user.id,
26
65
  },
27
66
  });
28
67
  });
29
68
 
30
- /** Exported authed procedure */
31
- export const authedProcedure = publicProcedure.use(isAuthed);
69
+ /**
70
+ * Error handling middleware
71
+ */
72
+ const errorHandler = middleware(async ({ next }) => {
73
+ try {
74
+ return await next();
75
+ } catch (error) {
76
+ throw toTRPCError(error);
77
+ }
78
+ });
79
+
80
+ /** Exported procedures with middleware */
81
+ export const authedProcedure = publicProcedure
82
+ .use(loggingMiddleware)
83
+ .use(errorHandler)
84
+ .use(isAuthed);