@studious-lms/server 1.5.0 → 1.7.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/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +3 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/pusher.d.ts +7 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +9 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/attendance.d.ts +16 -7
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -2
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/conversation.d.ts +33 -17
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +50 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +20 -9
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +62 -74
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts +13 -4
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +39 -27
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +31 -2
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +160 -28
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +48 -4
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +64 -4
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/conversation.d.ts +15 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/conversation.js +6 -3
- package/dist/routers/conversation.js.map +1 -1
- package/dist/routers/labChat.d.ts +1 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/message.d.ts +3 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/newtonChat.d.ts +1 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +4 -2
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +16 -6
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/announcement.js +4 -4
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +16 -6
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +68 -4
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/conversation.d.ts +16 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +22 -3
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/labChat.d.ts +8 -1
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +101 -42
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +3 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +8 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +1 -0
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +40 -17
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/worksheet.d.ts +15 -6
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +58 -12
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +50 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +167 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/index.js +13 -3
- package/dist/workers/index.js.map +1 -1
- package/dist/workers/processors/aiLabChat.d.ts +7 -0
- package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
- package/dist/workers/processors/aiLabChat.js +52 -0
- package/dist/workers/processors/aiLabChat.js.map +1 -0
- package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
- package/dist/workers/processors/aiNewtonChat.js +57 -0
- package/dist/workers/processors/aiNewtonChat.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
- package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +118 -0
- package/dist/workers/processors/createRunJobWorker.js.map +1 -0
- package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
- package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/gradeWorksheet.js +19 -0
- package/dist/workers/processors/gradeWorksheet.js.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts +5 -6
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.js +15 -95
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
- package/dist/workers/processors/studentProgressChat.d.ts +8 -0
- package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
- package/dist/workers/processors/studentProgressChat.js +27 -0
- package/dist/workers/processors/studentProgressChat.js.map +1 -0
- package/dist/workers/queues.d.ts +20 -4
- package/dist/workers/queues.d.ts.map +1 -1
- package/dist/workers/queues.js +3 -3
- package/dist/workers/queues.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +12 -1
- package/src/lib/config/cors.ts +1 -0
- package/src/lib/pusher.ts +7 -1
- package/src/models/attendance.ts +15 -0
- package/src/models/conversation.ts +54 -0
- package/src/pipelines/aiLabChat.ts +70 -78
- package/src/pipelines/aiNewtonChat.ts +47 -31
- package/src/pipelines/pdfToWorksheet.ts +224 -32
- package/src/routers/assignment.ts +74 -3
- package/src/routers/conversation.ts +7 -0
- package/src/routers/worksheet.ts +25 -4
- package/src/services/announcement.ts +2 -2
- package/src/services/assignment.ts +17 -4
- package/src/services/attendance.ts +87 -2
- package/src/services/conversation.ts +30 -0
- package/src/services/labChat.ts +106 -46
- package/src/services/message.ts +6 -0
- package/src/services/newtonChat.ts +45 -25
- package/src/services/worksheet.ts +71 -10
- package/src/utils/inference.ts +209 -1
- package/src/workers/index.ts +11 -1
- package/src/workers/processors/aiLabChat.ts +65 -0
- package/src/workers/processors/aiNewtonChat.ts +75 -0
- package/src/workers/processors/createRunJobWorker.ts +164 -0
- package/src/workers/processors/gradeWorksheet.ts +16 -0
- package/src/workers/processors/pdfToWorksheet.ts +12 -114
- package/src/workers/processors/studentProgressChat.ts +28 -0
- package/src/workers/queues.ts +20 -5
- package/tests/integration/routers/attendance.test.ts +60 -1
- package/tests/integration/routers/conversation.test.ts +121 -2
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PDF URL → worksheet: send a remote PDF URL to structured inference,
|
|
3
3
|
* then persist a Worksheet and WorksheetQuestion rows.
|
|
4
|
+
*
|
|
5
|
+
* Normally driven by a BullMQ worker, so the producer (e.g.
|
|
6
|
+
* `createWorksheetFromUploadedPdf`) creates a placeholder Worksheet row up
|
|
7
|
+
* front and passes its id in via `worksheetId`. The pipeline broadcasts
|
|
8
|
+
* `Worksheet.status` transitions on the per-worksheet job Pusher channel
|
|
9
|
+
* (`set-pending` / `set-completed` / `set-failed` / `set-cancelled`) — same
|
|
10
|
+
* convention as `gradeWorksheet`.
|
|
11
|
+
*
|
|
12
|
+
* The legacy `pdfToWorksheet(gcsUrl, classId)` signature is preserved for
|
|
13
|
+
* direct/synchronous callers (such as tests): when no `worksheetId` is
|
|
14
|
+
* supplied the pipeline creates the row itself.
|
|
4
15
|
*/
|
|
5
16
|
import { z } from "zod";
|
|
6
17
|
import type { ResponseInput } from "openai/resources/responses/responses.js";
|
|
7
|
-
import { Prisma } from "@prisma/client";
|
|
18
|
+
import { JobStatus, Prisma } from "@prisma/client";
|
|
8
19
|
import { prisma } from "../lib/prisma.js";
|
|
9
20
|
import { inference } from "../utils/inference.js";
|
|
10
21
|
import { logger } from "../utils/logger.js";
|
|
22
|
+
import { pusher, worksheetJobChannel } from "../lib/pusher.js";
|
|
11
23
|
|
|
12
24
|
/** Restrict remote fetches to Google-hosted object URLs. */
|
|
13
25
|
function isProbablyGoogleCloudUrl(url: string): boolean {
|
|
@@ -42,6 +54,61 @@ function sanitizeUrlForLogs(url: string): string {
|
|
|
42
54
|
}
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Pusher event names mirror `gradeWorksheet`'s convention so clients can
|
|
59
|
+
* share the same JobStatus → event-name mapping.
|
|
60
|
+
*/
|
|
61
|
+
const STATUS_EVENT_NAMES: Record<JobStatus, string> = {
|
|
62
|
+
NOT_STARTED: 'set-not-started',
|
|
63
|
+
PENDING: 'set-pending',
|
|
64
|
+
COMPLETED: 'set-completed',
|
|
65
|
+
FAILED: 'set-failed',
|
|
66
|
+
CANCELLED: 'set-cancelled',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Realtime payload for the per-worksheet job channel.
|
|
71
|
+
*
|
|
72
|
+
* On COMPLETED we ship the new title and question count so clients can
|
|
73
|
+
* patch their card optimistically and side-step replica lag on the next
|
|
74
|
+
* refetch. Error strings are intentionally NOT broadcast — they may carry
|
|
75
|
+
* server internals; the full error stays on `RunJob.error` for ops only.
|
|
76
|
+
*/
|
|
77
|
+
export interface WorksheetJobStatusEvent {
|
|
78
|
+
worksheetId: string;
|
|
79
|
+
status: JobStatus;
|
|
80
|
+
/** Filled on COMPLETED so clients can avoid a replica-lagged refetch. */
|
|
81
|
+
name?: string;
|
|
82
|
+
questionCount?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fire-and-forget broadcast of a `Worksheet.status` transition. We never let
|
|
87
|
+
* a Pusher outage break the actual pipeline: the persisted `Worksheet.status`
|
|
88
|
+
* is the source of truth, the channel just nudges clients.
|
|
89
|
+
*/
|
|
90
|
+
async function emitStatus(
|
|
91
|
+
worksheetId: string | undefined,
|
|
92
|
+
status: JobStatus,
|
|
93
|
+
extras: Pick<WorksheetJobStatusEvent, 'name' | 'questionCount'> = {},
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!worksheetId) return;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await pusher.trigger(
|
|
99
|
+
worksheetJobChannel(worksheetId),
|
|
100
|
+
STATUS_EVENT_NAMES[status],
|
|
101
|
+
{ worksheetId, status, ...extras } satisfies WorksheetJobStatusEvent,
|
|
102
|
+
);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.warn('pdfToWorksheet: failed to broadcast status update', {
|
|
105
|
+
worksheetId,
|
|
106
|
+
status,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
45
112
|
/** LLM output shape (validated by inference). */
|
|
46
113
|
const worksheetDraftSchema = z.object({
|
|
47
114
|
title: z.string(),
|
|
@@ -105,36 +172,72 @@ const schemaInstructions = `Return ONLY valid JSON matching this schema:
|
|
|
105
172
|
Rules: "questions" is an array ([] if none). Non-MCQ "options" can be []. Use "order" 1,2,3,… in reading order. No markdown or extra keys.
|
|
106
173
|
If no answer is provided for a multiple-choice or true/false question, determine the answer from the options.`;
|
|
107
174
|
|
|
175
|
+
export interface PdfToWorksheetOptions {
|
|
176
|
+
/**
|
|
177
|
+
* Pre-existing Worksheet row id. When supplied, the pipeline fills in this
|
|
178
|
+
* row instead of creating a new one and broadcasts progress on its job
|
|
179
|
+
* channel. This is the path used by the BullMQ worker.
|
|
180
|
+
*/
|
|
181
|
+
worksheetId?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
108
184
|
/**
|
|
109
185
|
* Sends a PDF URL to the model for remote retrieval, extracts a worksheet via
|
|
110
|
-
* structured inference, and inserts worksheet + questions
|
|
186
|
+
* structured inference, and inserts (or back-fills) worksheet + questions.
|
|
187
|
+
*
|
|
188
|
+
* When called with `options.worksheetId` the pipeline:
|
|
189
|
+
* - keeps that row's id stable (the client already routed to it),
|
|
190
|
+
* - drives `status` PENDING → COMPLETED / FAILED,
|
|
191
|
+
* - fans out `set-*` Pusher events on `worksheetJobChannel`.
|
|
111
192
|
*/
|
|
112
|
-
export async function pdfToWorksheet(
|
|
193
|
+
export async function pdfToWorksheet(
|
|
194
|
+
gcsUrl: string,
|
|
195
|
+
classId: string,
|
|
196
|
+
options: PdfToWorksheetOptions = {},
|
|
197
|
+
) {
|
|
113
198
|
const sourceUrl = sanitizeUrlForLogs(gcsUrl);
|
|
199
|
+
const placeholderWorksheetId = options.worksheetId;
|
|
200
|
+
|
|
201
|
+
await emitStatus(placeholderWorksheetId, JobStatus.PENDING);
|
|
114
202
|
|
|
115
203
|
if (!isProbablyGoogleCloudUrl(gcsUrl)) {
|
|
116
204
|
logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
|
|
117
205
|
sanitizedUrl: sourceUrl,
|
|
118
206
|
});
|
|
207
|
+
await markWorksheetFailed(
|
|
208
|
+
placeholderWorksheetId,
|
|
209
|
+
"Only Google Cloud Storage URLs are supported",
|
|
210
|
+
);
|
|
119
211
|
throw new Error("Only Google Cloud Storage URLs are supported");
|
|
120
212
|
}
|
|
121
213
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
},
|
|
214
|
+
// Look for a previously-completed worksheet pointing at the same source PDF.
|
|
215
|
+
// If there is one (and it's not the placeholder we were handed) we reuse it,
|
|
216
|
+
// mark the placeholder CANCELLED so the editor stops spinning, and return
|
|
217
|
+
// the canonical worksheet.
|
|
218
|
+
const existingForSource = await prisma.worksheet.findUnique({
|
|
219
|
+
where: { classId_sourceUrl: { classId, sourceUrl } },
|
|
129
220
|
});
|
|
130
221
|
|
|
131
|
-
if (
|
|
222
|
+
if (existingForSource && existingForSource.id !== placeholderWorksheetId) {
|
|
132
223
|
logger.info("pdfToWorksheet: reusing existing worksheet for source PDF", {
|
|
133
|
-
worksheetId:
|
|
224
|
+
worksheetId: existingForSource.id,
|
|
134
225
|
classId,
|
|
135
226
|
sourceUrl,
|
|
136
227
|
});
|
|
137
|
-
|
|
228
|
+
|
|
229
|
+
if (placeholderWorksheetId) {
|
|
230
|
+
await prisma.worksheet
|
|
231
|
+
.update({
|
|
232
|
+
where: { id: placeholderWorksheetId },
|
|
233
|
+
data: { status: JobStatus.CANCELLED },
|
|
234
|
+
})
|
|
235
|
+
.catch(() => undefined);
|
|
236
|
+
|
|
237
|
+
await emitStatus(placeholderWorksheetId, JobStatus.CANCELLED);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return existingForSource;
|
|
138
241
|
}
|
|
139
242
|
|
|
140
243
|
const filename = getFilenameFromUrl(gcsUrl);
|
|
@@ -151,33 +254,67 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
|
151
254
|
},
|
|
152
255
|
];
|
|
153
256
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
257
|
+
let draft: WorksheetDraft | null;
|
|
258
|
+
try {
|
|
259
|
+
draft = await inference<WorksheetDraft>(
|
|
260
|
+
input,
|
|
261
|
+
worksheetDraftSchema,
|
|
262
|
+
{
|
|
263
|
+
reason: 'pdf_to_worksheet_draft',
|
|
264
|
+
metadata: {
|
|
265
|
+
classId,
|
|
266
|
+
filename,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
272
|
+
await markWorksheetFailed(placeholderWorksheetId, message);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
165
275
|
|
|
166
276
|
if (!draft) {
|
|
167
277
|
logger.warn("pdfToWorksheet: no model output", { filename });
|
|
278
|
+
await markWorksheetFailed(
|
|
279
|
+
placeholderWorksheetId,
|
|
280
|
+
"Model returned no parseable worksheet output",
|
|
281
|
+
);
|
|
168
282
|
return null;
|
|
169
283
|
}
|
|
170
284
|
|
|
171
285
|
try {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
286
|
+
const worksheet = await prisma.$transaction(async (tx) => {
|
|
287
|
+
const created = placeholderWorksheetId
|
|
288
|
+
? await tx.worksheet.update({
|
|
289
|
+
where: { id: placeholderWorksheetId },
|
|
290
|
+
data: {
|
|
291
|
+
classId,
|
|
292
|
+
name: draft!.title,
|
|
293
|
+
sourceUrl,
|
|
294
|
+
status: JobStatus.PENDING,
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
: await tx.worksheet.create({
|
|
298
|
+
data: {
|
|
299
|
+
classId,
|
|
300
|
+
name: draft!.title,
|
|
301
|
+
sourceUrl,
|
|
302
|
+
status: JobStatus.PENDING,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (draft!.questions.length > 0) {
|
|
307
|
+
// When re-running against an existing placeholder, drop any prior
|
|
308
|
+
// questions so we don't accumulate duplicates from retries.
|
|
309
|
+
if (placeholderWorksheetId) {
|
|
310
|
+
await tx.worksheetQuestion.deleteMany({
|
|
311
|
+
where: { worksheetId: created.id },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
176
314
|
|
|
177
|
-
if (draft.questions.length > 0) {
|
|
178
315
|
await tx.worksheetQuestion.createMany({
|
|
179
|
-
data: draft
|
|
180
|
-
worksheetId:
|
|
316
|
+
data: draft!.questions.map((question) => ({
|
|
317
|
+
worksheetId: created.id,
|
|
181
318
|
type: question.type,
|
|
182
319
|
question: question.question,
|
|
183
320
|
answer: question.answer,
|
|
@@ -192,8 +329,18 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
|
192
329
|
});
|
|
193
330
|
}
|
|
194
331
|
|
|
195
|
-
return worksheet
|
|
332
|
+
return tx.worksheet.update({
|
|
333
|
+
where: { id: created.id },
|
|
334
|
+
data: { status: JobStatus.COMPLETED },
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await emitStatus(placeholderWorksheetId, JobStatus.COMPLETED, {
|
|
339
|
+
name: worksheet.name,
|
|
340
|
+
questionCount: draft.questions.length,
|
|
196
341
|
});
|
|
342
|
+
|
|
343
|
+
return worksheet;
|
|
197
344
|
} catch (error) {
|
|
198
345
|
if (
|
|
199
346
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
@@ -214,10 +361,55 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
|
214
361
|
classId,
|
|
215
362
|
sourceUrl,
|
|
216
363
|
});
|
|
364
|
+
|
|
365
|
+
if (placeholderWorksheetId && placeholderWorksheetId !== worksheet.id) {
|
|
366
|
+
await prisma.worksheet
|
|
367
|
+
.update({
|
|
368
|
+
where: { id: placeholderWorksheetId },
|
|
369
|
+
data: { status: JobStatus.CANCELLED },
|
|
370
|
+
})
|
|
371
|
+
.catch(() => undefined);
|
|
372
|
+
|
|
373
|
+
await emitStatus(placeholderWorksheetId, JobStatus.CANCELLED);
|
|
374
|
+
}
|
|
375
|
+
|
|
217
376
|
return worksheet;
|
|
218
377
|
}
|
|
219
378
|
}
|
|
220
379
|
|
|
380
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
381
|
+
await markWorksheetFailed(placeholderWorksheetId, message);
|
|
221
382
|
throw error;
|
|
222
383
|
}
|
|
223
384
|
}
|
|
385
|
+
|
|
386
|
+
async function markWorksheetFailed(
|
|
387
|
+
worksheetId: string | undefined,
|
|
388
|
+
errorMessage: string,
|
|
389
|
+
) {
|
|
390
|
+
if (!worksheetId) return;
|
|
391
|
+
|
|
392
|
+
await prisma.worksheet
|
|
393
|
+
.update({
|
|
394
|
+
where: { id: worksheetId },
|
|
395
|
+
data: { status: JobStatus.FAILED },
|
|
396
|
+
})
|
|
397
|
+
.catch((updateError) => {
|
|
398
|
+
logger.error('pdfToWorksheet: failed to mark worksheet FAILED', {
|
|
399
|
+
worksheetId,
|
|
400
|
+
error:
|
|
401
|
+
updateError instanceof Error
|
|
402
|
+
? updateError.message
|
|
403
|
+
: String(updateError),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// The full error is logged + persisted on `RunJob.error`; the broadcast
|
|
408
|
+
// intentionally carries no error payload to avoid leaking internals.
|
|
409
|
+
logger.error('pdfToWorksheet: marking worksheet FAILED', {
|
|
410
|
+
worksheetId,
|
|
411
|
+
error: errorMessage,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
await emitStatus(worksheetId, JobStatus.FAILED);
|
|
415
|
+
}
|
|
@@ -5,7 +5,6 @@ import { prisma } from "../lib/prisma.js";
|
|
|
5
5
|
import { createDirectUploadFiles, type DirectUploadFile, confirmDirectUpload, updateUploadProgress, type UploadedFile } from "../lib/fileUpload.js";
|
|
6
6
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
|
-
import { gradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
9
8
|
import {
|
|
10
9
|
classMemberOrSchoolAdminWhere,
|
|
11
10
|
classTeacherOrSchoolAdminWhere,
|
|
@@ -35,6 +34,15 @@ import {
|
|
|
35
34
|
} from "../services/assignment.js";
|
|
36
35
|
import { sendToMultiple } from "../services/notification.js";
|
|
37
36
|
|
|
37
|
+
function getCommenterName(comment: {
|
|
38
|
+
author: {
|
|
39
|
+
username: string;
|
|
40
|
+
profile: { displayName: string | null } | null;
|
|
41
|
+
};
|
|
42
|
+
}) {
|
|
43
|
+
return comment.author.profile?.displayName || comment.author.username;
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
// DEPRECATED: This schema is no longer used - files are uploaded directly to GCS
|
|
39
47
|
// Use directFileSchema instead
|
|
40
48
|
|
|
@@ -369,7 +377,18 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
369
377
|
.mutation(async ({ ctx, input }) => {
|
|
370
378
|
const assignment = await prisma.assignment.findFirst({
|
|
371
379
|
where: { id: input.assignmentId, classId: input.classId },
|
|
372
|
-
select: {
|
|
380
|
+
select: {
|
|
381
|
+
id: true,
|
|
382
|
+
title: true,
|
|
383
|
+
class: {
|
|
384
|
+
select: {
|
|
385
|
+
id: true,
|
|
386
|
+
name: true,
|
|
387
|
+
students: { select: { id: true } },
|
|
388
|
+
teachers: { select: { id: true } },
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
373
392
|
});
|
|
374
393
|
if (!assignment) {
|
|
375
394
|
throw new TRPCError({
|
|
@@ -405,6 +424,24 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
405
424
|
include: commentInclude,
|
|
406
425
|
});
|
|
407
426
|
|
|
427
|
+
const authorId = ctx.user!.id;
|
|
428
|
+
const receiverIds = Array.from(
|
|
429
|
+
new Set([
|
|
430
|
+
...assignment.class.students.map((s) => s.id),
|
|
431
|
+
...assignment.class.teachers.map((t) => t.id),
|
|
432
|
+
])
|
|
433
|
+
).filter((id) => id !== authorId);
|
|
434
|
+
|
|
435
|
+
if (receiverIds.length > 0) {
|
|
436
|
+
sendToMultiple({
|
|
437
|
+
receiverIds,
|
|
438
|
+
title: `New comment on ${assignment.title}`,
|
|
439
|
+
content: `${getCommenterName(comment)} commented in ${assignment.class.name}:\n\n${input.content}\n\n[View assignment](/class/${assignment.class.id}/assignments/${assignment.id})`,
|
|
440
|
+
}).catch((error) => {
|
|
441
|
+
logger.error("Failed to send assignment comment notifications:", error);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
408
445
|
return { comment };
|
|
409
446
|
}),
|
|
410
447
|
|
|
@@ -458,7 +495,23 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
458
495
|
{ assignment: { class: classTeacherOrSchoolAdminWhere(ctx.user!.id) } },
|
|
459
496
|
],
|
|
460
497
|
},
|
|
461
|
-
select: {
|
|
498
|
+
select: {
|
|
499
|
+
id: true,
|
|
500
|
+
studentId: true,
|
|
501
|
+
assignment: {
|
|
502
|
+
select: {
|
|
503
|
+
id: true,
|
|
504
|
+
title: true,
|
|
505
|
+
class: {
|
|
506
|
+
select: {
|
|
507
|
+
id: true,
|
|
508
|
+
name: true,
|
|
509
|
+
teachers: { select: { id: true } },
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
},
|
|
462
515
|
});
|
|
463
516
|
if (!submission) {
|
|
464
517
|
throw new TRPCError({
|
|
@@ -493,6 +546,24 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
493
546
|
include: commentInclude,
|
|
494
547
|
});
|
|
495
548
|
|
|
549
|
+
const authorId = ctx.user!.id;
|
|
550
|
+
const receiverIds = Array.from(
|
|
551
|
+
new Set([
|
|
552
|
+
submission.studentId,
|
|
553
|
+
...submission.assignment.class.teachers.map((t) => t.id),
|
|
554
|
+
])
|
|
555
|
+
).filter((id) => id !== authorId);
|
|
556
|
+
|
|
557
|
+
if (receiverIds.length > 0) {
|
|
558
|
+
sendToMultiple({
|
|
559
|
+
receiverIds,
|
|
560
|
+
title: `New private comment on ${submission.assignment.title}`,
|
|
561
|
+
content: `${getCommenterName(comment)} commented on a submission in ${submission.assignment.class.name}:\n\n${input.content}\n\n[View submission](/class/${submission.assignment.class.id}/assignments/${submission.assignment.id}/submissions/${submission.id})`,
|
|
562
|
+
}).catch((error) => {
|
|
563
|
+
logger.error("Failed to send submission comment notifications:", error);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
496
567
|
return { comment };
|
|
497
568
|
}),
|
|
498
569
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
addMemberToConversation,
|
|
8
8
|
hideConversation,
|
|
9
9
|
removeMemberFromConversation,
|
|
10
|
+
searchConversationMembers,
|
|
10
11
|
} from "../services/conversation.js";
|
|
11
12
|
|
|
12
13
|
export const conversationRouter = createTRPCRouter({
|
|
@@ -14,6 +15,12 @@ export const conversationRouter = createTRPCRouter({
|
|
|
14
15
|
listConversations(ctx.user!.id)
|
|
15
16
|
),
|
|
16
17
|
|
|
18
|
+
searchMembers: protectedProcedure
|
|
19
|
+
.input(z.object({ query: z.string().max(100) }))
|
|
20
|
+
.query(({ ctx, input }) =>
|
|
21
|
+
searchConversationMembers(ctx.user!.id, input.query)
|
|
22
|
+
),
|
|
23
|
+
|
|
17
24
|
create: protectedProcedure
|
|
18
25
|
.input(
|
|
19
26
|
z.object({
|
package/src/routers/worksheet.ts
CHANGED
|
@@ -127,8 +127,18 @@ export const worksheetRouter = createTRPCRouter({
|
|
|
127
127
|
),
|
|
128
128
|
|
|
129
129
|
createFromPdf: protectedTeacherProcedure
|
|
130
|
-
.input(
|
|
131
|
-
|
|
130
|
+
.input(
|
|
131
|
+
z.object({
|
|
132
|
+
classId: z.string(),
|
|
133
|
+
url: z.string().url(),
|
|
134
|
+
name: z.string().optional(),
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
.mutation(({ ctx, input }) =>
|
|
138
|
+
createWorksheetFromPdf(input.classId, input.url, ctx.user!.id, {
|
|
139
|
+
name: input.name,
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
132
142
|
|
|
133
143
|
getWorksheetUploadUrls: protectedTeacherProcedure
|
|
134
144
|
.input(getWorksheetUploadUrlsSchema)
|
|
@@ -190,9 +200,20 @@ export const worksheetRouter = createTRPCRouter({
|
|
|
190
200
|
}),
|
|
191
201
|
|
|
192
202
|
createFromUploadedPdf: protectedTeacherProcedure
|
|
193
|
-
.input(
|
|
203
|
+
.input(
|
|
204
|
+
z.object({
|
|
205
|
+
classId: z.string(),
|
|
206
|
+
fileId: z.string(),
|
|
207
|
+
name: z.string().optional(),
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
194
210
|
.mutation(({ ctx, input }) =>
|
|
195
|
-
createWorksheetFromUploadedPdf(
|
|
211
|
+
createWorksheetFromUploadedPdf(
|
|
212
|
+
input.classId,
|
|
213
|
+
input.fileId,
|
|
214
|
+
ctx.user!.id,
|
|
215
|
+
{ name: input.name },
|
|
216
|
+
)
|
|
196
217
|
),
|
|
197
218
|
|
|
198
219
|
addQuestion: protectedProcedure
|
|
@@ -121,8 +121,8 @@ export async function createAnnouncementRecord(
|
|
|
121
121
|
|
|
122
122
|
sendToMultiple({
|
|
123
123
|
receiverIds: classData.students.map((s) => s.id),
|
|
124
|
-
title:
|
|
125
|
-
content: remarks
|
|
124
|
+
title: `New announcement in ${classData.name}`,
|
|
125
|
+
content: `${remarks}\n\n[View announcement](/class/${classId})`,
|
|
126
126
|
}).catch((error) => {
|
|
127
127
|
logger.error("Failed to send announcement notifications:", error);
|
|
128
128
|
});
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
} from "../models/assignment.js";
|
|
24
24
|
import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
|
|
25
25
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
26
|
-
import { gradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
27
26
|
import { sendToMultiple } from "./notification.js";
|
|
27
|
+
import { enqueueRunJob } from "../workers/queues.js";
|
|
28
28
|
import { logger } from "../utils/logger.js";
|
|
29
29
|
|
|
30
30
|
export async function assignmentExists(id: string) {
|
|
@@ -312,8 +312,8 @@ export async function createAssignmentRecord(
|
|
|
312
312
|
|
|
313
313
|
sendToMultiple({
|
|
314
314
|
receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
|
|
315
|
-
title: `New assignment
|
|
316
|
-
content: `
|
|
315
|
+
title: `New assignment in ${classData.name}`,
|
|
316
|
+
content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
|
|
317
317
|
}).catch((error) => {
|
|
318
318
|
logger.error("Failed to send assignment notifications:", error);
|
|
319
319
|
});
|
|
@@ -664,9 +664,22 @@ export async function updateSubmissionRecord(
|
|
|
664
664
|
) {
|
|
665
665
|
const worksheetResponses = await prisma.studentWorksheetResponse.findMany({
|
|
666
666
|
where: { submissionId: submission.id },
|
|
667
|
+
select: { id: true },
|
|
667
668
|
});
|
|
668
669
|
for (const wr of worksheetResponses) {
|
|
669
|
-
|
|
670
|
+
try {
|
|
671
|
+
await enqueueRunJob(
|
|
672
|
+
"GRADE_WORKSHEET",
|
|
673
|
+
{ worksheetResponseId: wr.id },
|
|
674
|
+
{ classId: submission.assignment.classId, userId },
|
|
675
|
+
);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
logger.error("Failed to enqueue worksheet grading on submit", {
|
|
678
|
+
error,
|
|
679
|
+
worksheetResponseId: wr.id,
|
|
680
|
+
submissionId: submission.id,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
670
683
|
}
|
|
671
684
|
}
|
|
672
685
|
return prisma.submission.update({
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
createAttendanceForEvent,
|
|
15
15
|
updateAttendance,
|
|
16
16
|
} from "../models/attendance.js";
|
|
17
|
+
import { sendToMultiple } from "./notification.js";
|
|
18
|
+
import { logger } from "../utils/logger.js";
|
|
17
19
|
|
|
18
20
|
/** Get attendance for a class (optionally filtered by event). Creates missing records. */
|
|
19
21
|
export async function getAttendance(classId: string, eventId?: string) {
|
|
@@ -66,20 +68,103 @@ export async function updateAttendanceRecord(
|
|
|
66
68
|
const presentIds = attendance.present.map((s) => s.id);
|
|
67
69
|
const lateIds = attendance.late.map((s) => s.id);
|
|
68
70
|
const absentIds = attendance.absent.map((s) => s.id);
|
|
71
|
+
const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
|
|
72
|
+
const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);
|
|
73
|
+
const classStudentIds = new Set(classData.students.map((student) => student.id));
|
|
69
74
|
|
|
70
75
|
if (!existing) {
|
|
71
|
-
|
|
76
|
+
const created = await createAttendance({
|
|
72
77
|
classId,
|
|
73
78
|
eventId: eventId ?? null,
|
|
74
79
|
presentIds,
|
|
75
80
|
lateIds,
|
|
76
81
|
absentIds,
|
|
77
82
|
});
|
|
83
|
+
await notifyMarkedLateOrAbsent({
|
|
84
|
+
classId,
|
|
85
|
+
className: classData.name,
|
|
86
|
+
eventId: eventId ?? null,
|
|
87
|
+
eventName: created.event?.name ?? null,
|
|
88
|
+
classStudentIds,
|
|
89
|
+
lateIds,
|
|
90
|
+
absentIds,
|
|
91
|
+
previousLateIds,
|
|
92
|
+
previousAbsentIds,
|
|
93
|
+
});
|
|
94
|
+
return created;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
|
|
97
|
+
const updated = await updateAttendance(existing.id, {
|
|
81
98
|
presentIds,
|
|
82
99
|
lateIds,
|
|
83
100
|
absentIds,
|
|
84
101
|
});
|
|
102
|
+
await notifyMarkedLateOrAbsent({
|
|
103
|
+
classId,
|
|
104
|
+
className: classData.name,
|
|
105
|
+
eventId: eventId ?? null,
|
|
106
|
+
eventName: existing.event?.name ?? null,
|
|
107
|
+
classStudentIds,
|
|
108
|
+
lateIds,
|
|
109
|
+
absentIds,
|
|
110
|
+
previousLateIds,
|
|
111
|
+
previousAbsentIds,
|
|
112
|
+
});
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Notify only students whose late or absent status is newly added. */
|
|
117
|
+
async function notifyMarkedLateOrAbsent(input: {
|
|
118
|
+
classId: string;
|
|
119
|
+
className: string;
|
|
120
|
+
eventId: string | null;
|
|
121
|
+
eventName: string | null;
|
|
122
|
+
classStudentIds: Set<string>;
|
|
123
|
+
lateIds: string[];
|
|
124
|
+
absentIds: string[];
|
|
125
|
+
previousLateIds: Set<string>;
|
|
126
|
+
previousAbsentIds: Set<string>;
|
|
127
|
+
}) {
|
|
128
|
+
const lateReceiverIds = [...new Set(input.lateIds)].filter(
|
|
129
|
+
(id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id)
|
|
130
|
+
);
|
|
131
|
+
const absentReceiverIds = [...new Set(input.absentIds)].filter(
|
|
132
|
+
(id) => input.classStudentIds.has(id) && !input.previousAbsentIds.has(id)
|
|
133
|
+
);
|
|
134
|
+
const link = input.eventId
|
|
135
|
+
? `/class/${input.classId}/attendance/${input.eventId}`
|
|
136
|
+
: `/class/${input.classId}/attendance`;
|
|
137
|
+
const attendanceLabel = input.eventName
|
|
138
|
+
? `"${input.eventName}" in ${input.className}`
|
|
139
|
+
: input.className;
|
|
140
|
+
|
|
141
|
+
await Promise.all([
|
|
142
|
+
sendAttendanceNotification({
|
|
143
|
+
receiverIds: lateReceiverIds,
|
|
144
|
+
title: `Marked late in ${input.className}`,
|
|
145
|
+
content: `You were marked late for ${attendanceLabel}.\n[View attendance](${link})`,
|
|
146
|
+
}),
|
|
147
|
+
sendAttendanceNotification({
|
|
148
|
+
receiverIds: absentReceiverIds,
|
|
149
|
+
title: `Marked absent in ${input.className}`,
|
|
150
|
+
content: `You were marked absent for ${attendanceLabel}.\n[View attendance](${link})`,
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Send attendance notifications without failing the attendance update itself. */
|
|
156
|
+
async function sendAttendanceNotification(input: {
|
|
157
|
+
receiverIds: string[];
|
|
158
|
+
title: string;
|
|
159
|
+
content: string;
|
|
160
|
+
}) {
|
|
161
|
+
if (input.receiverIds.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await sendToMultiple(input);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.error("Failed to send attendance notifications:", { error });
|
|
169
|
+
}
|
|
85
170
|
}
|