@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.
- package/ANALYSIS_PROGRESS_SPEC.md +463 -0
- package/PROGRESS_QUICK_REFERENCE.md +239 -0
- package/dist/lib/ai-session.d.ts +20 -9
- package/dist/lib/ai-session.js +316 -80
- package/dist/lib/auth.d.ts +35 -2
- package/dist/lib/auth.js +88 -15
- package/dist/lib/env.d.ts +32 -0
- package/dist/lib/env.js +46 -0
- package/dist/lib/errors.d.ts +33 -0
- package/dist/lib/errors.js +78 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +9 -11
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.js +342 -0
- package/dist/lib/podcast-prompts.d.ts +43 -0
- package/dist/lib/podcast-prompts.js +135 -0
- package/dist/lib/pusher.d.ts +1 -0
- package/dist/lib/pusher.js +14 -2
- package/dist/lib/storage.d.ts +3 -3
- package/dist/lib/storage.js +51 -47
- package/dist/lib/validation.d.ts +51 -0
- package/dist/lib/validation.js +64 -0
- package/dist/routers/_app.d.ts +697 -111
- package/dist/routers/_app.js +5 -0
- package/dist/routers/auth.d.ts +11 -1
- package/dist/routers/chat.d.ts +11 -1
- package/dist/routers/flashcards.d.ts +205 -6
- package/dist/routers/flashcards.js +144 -66
- package/dist/routers/members.d.ts +165 -0
- package/dist/routers/members.js +531 -0
- package/dist/routers/podcast.d.ts +78 -63
- package/dist/routers/podcast.js +330 -393
- package/dist/routers/studyguide.d.ts +11 -1
- package/dist/routers/worksheets.d.ts +124 -13
- package/dist/routers/worksheets.js +123 -50
- package/dist/routers/workspace.d.ts +213 -26
- package/dist/routers/workspace.js +303 -181
- package/dist/server.js +12 -4
- package/dist/services/flashcard-progress.service.d.ts +183 -0
- package/dist/services/flashcard-progress.service.js +383 -0
- package/dist/services/flashcard.service.d.ts +183 -0
- package/dist/services/flashcard.service.js +224 -0
- package/dist/services/podcast-segment-reorder.d.ts +0 -0
- package/dist/services/podcast-segment-reorder.js +107 -0
- package/dist/services/podcast.service.d.ts +0 -0
- package/dist/services/podcast.service.js +326 -0
- package/dist/services/worksheet.service.d.ts +0 -0
- package/dist/services/worksheet.service.js +295 -0
- package/dist/trpc.d.ts +13 -2
- package/dist/trpc.js +55 -6
- package/dist/types/index.d.ts +126 -0
- package/dist/types/index.js +1 -0
- package/package.json +3 -2
- package/prisma/schema.prisma +142 -4
- package/src/lib/ai-session.ts +356 -85
- package/src/lib/auth.ts +113 -19
- package/src/lib/env.ts +59 -0
- package/src/lib/errors.ts +92 -0
- package/src/lib/inference.ts +11 -11
- package/src/lib/logger.ts +405 -0
- package/src/lib/pusher.ts +15 -3
- package/src/lib/storage.ts +56 -51
- package/src/lib/validation.ts +75 -0
- package/src/routers/_app.ts +5 -0
- package/src/routers/chat.ts +2 -23
- package/src/routers/flashcards.ts +108 -24
- package/src/routers/members.ts +586 -0
- package/src/routers/podcast.ts +385 -420
- package/src/routers/worksheets.ts +117 -35
- package/src/routers/workspace.ts +328 -195
- package/src/server.ts +13 -4
- package/src/services/flashcard-progress.service.ts +541 -0
- package/src/trpc.ts +59 -6
- package/src/types/index.ts +165 -0
- package/AUTH_FRONTEND_SPEC.md +0 -21
- package/CHAT_FRONTEND_SPEC.md +0 -474
- package/DATABASE_SETUP.md +0 -165
- package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
- package/PODCAST_FRONTEND_SPEC.md +0 -595
- package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
- package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
- package/WORKSPACE_FRONTEND_SPEC.md +0 -47
- 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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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({
|
|
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
|
-
/**
|
|
31
|
-
|
|
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);
|