@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.
- package/.env.example +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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: {
|
|
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:
|
|
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:
|
|
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: {
|
|
77
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
231
|
-
back
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
360
|
+
return { artifact, createdCards };
|
|
245
361
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|