@studious-lms/server 1.4.9 → 1.6.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 (237) hide show
  1. package/dist/index.js +15 -3
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/config/cors.d.ts.map +1 -1
  4. package/dist/lib/config/cors.js +2 -3
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts.map +1 -1
  7. package/dist/lib/fileUpload.js +4 -5
  8. package/dist/lib/fileUpload.js.map +1 -1
  9. package/dist/lib/pusher.d.ts +7 -1
  10. package/dist/lib/pusher.d.ts.map +1 -1
  11. package/dist/lib/pusher.js +9 -3
  12. package/dist/lib/pusher.js.map +1 -1
  13. package/dist/models/attendance.d.ts +16 -7
  14. package/dist/models/attendance.d.ts.map +1 -1
  15. package/dist/models/attendance.js +17 -2
  16. package/dist/models/attendance.js.map +1 -1
  17. package/dist/models/auth.d.ts +1 -0
  18. package/dist/models/auth.d.ts.map +1 -1
  19. package/dist/models/auth.js +3 -2
  20. package/dist/models/auth.js.map +1 -1
  21. package/dist/models/comment.d.ts +0 -5
  22. package/dist/models/comment.d.ts.map +1 -1
  23. package/dist/models/conversation.d.ts +17 -16
  24. package/dist/models/conversation.d.ts.map +1 -1
  25. package/dist/models/labChat.d.ts +2 -1
  26. package/dist/models/labChat.d.ts.map +1 -1
  27. package/dist/models/message.d.ts +6 -3
  28. package/dist/models/message.d.ts.map +1 -1
  29. package/dist/models/school.d.ts +0 -22
  30. package/dist/models/school.d.ts.map +1 -1
  31. package/dist/models/school.js +2 -14
  32. package/dist/models/school.js.map +1 -1
  33. package/dist/models/worksheet.d.ts +76 -19
  34. package/dist/models/worksheet.d.ts.map +1 -1
  35. package/dist/models/worksheet.js +2 -2
  36. package/dist/models/worksheet.js.map +1 -1
  37. package/dist/pipelines/aiLabChat.d.ts +20 -9
  38. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  39. package/dist/pipelines/aiLabChat.js +63 -75
  40. package/dist/pipelines/aiLabChat.js.map +1 -1
  41. package/dist/pipelines/aiNewtonChat.d.ts +13 -4
  42. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  43. package/dist/pipelines/aiNewtonChat.js +39 -27
  44. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  45. package/dist/pipelines/gradeWorksheet.d.ts +5 -3
  46. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  47. package/dist/pipelines/gradeWorksheet.js +60 -24
  48. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  49. package/dist/pipelines/pdfToWorksheet.d.ts +34 -2
  50. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  51. package/dist/pipelines/pdfToWorksheet.js +205 -32
  52. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  53. package/dist/routers/_app.d.ts +106 -48
  54. package/dist/routers/_app.d.ts.map +1 -1
  55. package/dist/routers/announcement.d.ts +0 -4
  56. package/dist/routers/announcement.d.ts.map +1 -1
  57. package/dist/routers/assignment.d.ts +0 -8
  58. package/dist/routers/assignment.d.ts.map +1 -1
  59. package/dist/routers/assignment.js +64 -4
  60. package/dist/routers/assignment.js.map +1 -1
  61. package/dist/routers/auth.d.ts +1 -0
  62. package/dist/routers/auth.d.ts.map +1 -1
  63. package/dist/routers/comment.d.ts +0 -3
  64. package/dist/routers/comment.d.ts.map +1 -1
  65. package/dist/routers/conversation.d.ts +2 -1
  66. package/dist/routers/conversation.d.ts.map +1 -1
  67. package/dist/routers/labChat.d.ts +3 -1
  68. package/dist/routers/labChat.d.ts.map +1 -1
  69. package/dist/routers/message.d.ts +3 -0
  70. package/dist/routers/message.d.ts.map +1 -1
  71. package/dist/routers/newtonChat.d.ts +1 -0
  72. package/dist/routers/newtonChat.d.ts.map +1 -1
  73. package/dist/routers/school.d.ts +6 -0
  74. package/dist/routers/school.d.ts.map +1 -1
  75. package/dist/routers/school.js +7 -2
  76. package/dist/routers/school.js.map +1 -1
  77. package/dist/routers/worksheet.d.ts +37 -7
  78. package/dist/routers/worksheet.d.ts.map +1 -1
  79. package/dist/routers/worksheet.js +16 -6
  80. package/dist/routers/worksheet.js.map +1 -1
  81. package/dist/services/announcement.js +4 -4
  82. package/dist/services/announcement.js.map +1 -1
  83. package/dist/services/assignment.d.ts.map +1 -1
  84. package/dist/services/assignment.js +16 -6
  85. package/dist/services/assignment.js.map +1 -1
  86. package/dist/services/attendance.d.ts.map +1 -1
  87. package/dist/services/attendance.js +68 -4
  88. package/dist/services/attendance.js.map +1 -1
  89. package/dist/services/auth.d.ts +1 -0
  90. package/dist/services/auth.d.ts.map +1 -1
  91. package/dist/services/comment.d.ts +0 -3
  92. package/dist/services/comment.d.ts.map +1 -1
  93. package/dist/services/conversation.d.ts +2 -1
  94. package/dist/services/conversation.d.ts.map +1 -1
  95. package/dist/services/labChat.d.ts +10 -2
  96. package/dist/services/labChat.d.ts.map +1 -1
  97. package/dist/services/labChat.js +103 -44
  98. package/dist/services/labChat.js.map +1 -1
  99. package/dist/services/message.d.ts +3 -0
  100. package/dist/services/message.d.ts.map +1 -1
  101. package/dist/services/message.js +20 -2
  102. package/dist/services/message.js.map +1 -1
  103. package/dist/services/newtonChat.d.ts +1 -0
  104. package/dist/services/newtonChat.d.ts.map +1 -1
  105. package/dist/services/newtonChat.js +40 -17
  106. package/dist/services/newtonChat.js.map +1 -1
  107. package/dist/services/school.d.ts +6 -0
  108. package/dist/services/school.d.ts.map +1 -1
  109. package/dist/services/school.js +71 -6
  110. package/dist/services/school.js.map +1 -1
  111. package/dist/services/worksheet.d.ts +48 -11
  112. package/dist/services/worksheet.d.ts.map +1 -1
  113. package/dist/services/worksheet.js +73 -29
  114. package/dist/services/worksheet.js.map +1 -1
  115. package/dist/utils/generateUsername.d.ts +8 -0
  116. package/dist/utils/generateUsername.d.ts.map +1 -0
  117. package/dist/utils/generateUsername.js +103 -0
  118. package/dist/utils/generateUsername.js.map +1 -0
  119. package/dist/utils/inference.d.ts +50 -1
  120. package/dist/utils/inference.d.ts.map +1 -1
  121. package/dist/utils/inference.js +167 -2
  122. package/dist/utils/inference.js.map +1 -1
  123. package/dist/workers/connection.d.ts +3 -0
  124. package/dist/workers/connection.d.ts.map +1 -0
  125. package/dist/workers/connection.js +33 -0
  126. package/dist/workers/connection.js.map +1 -0
  127. package/dist/workers/index.d.ts +10 -0
  128. package/dist/workers/index.d.ts.map +1 -0
  129. package/dist/workers/index.js +63 -0
  130. package/dist/workers/index.js.map +1 -0
  131. package/dist/workers/processors/aiLabChat.d.ts +7 -0
  132. package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
  133. package/dist/workers/processors/aiLabChat.js +52 -0
  134. package/dist/workers/processors/aiLabChat.js.map +1 -0
  135. package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
  136. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
  137. package/dist/workers/processors/aiNewtonChat.js +57 -0
  138. package/dist/workers/processors/aiNewtonChat.js.map +1 -0
  139. package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
  140. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
  141. package/dist/workers/processors/createRunJobWorker.js +118 -0
  142. package/dist/workers/processors/createRunJobWorker.js.map +1 -0
  143. package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
  144. package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
  145. package/dist/workers/processors/gradeWorksheet.js +19 -0
  146. package/dist/workers/processors/gradeWorksheet.js.map +1 -0
  147. package/dist/workers/processors/pdfToWorksheet.d.ts +8 -0
  148. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
  149. package/dist/workers/processors/pdfToWorksheet.js +20 -0
  150. package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
  151. package/dist/workers/processors/studentProgressChat.d.ts +8 -0
  152. package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
  153. package/dist/workers/processors/studentProgressChat.js +27 -0
  154. package/dist/workers/processors/studentProgressChat.js.map +1 -0
  155. package/dist/workers/queues.d.ts +85 -0
  156. package/dist/workers/queues.d.ts.map +1 -0
  157. package/dist/workers/queues.js +119 -0
  158. package/dist/workers/queues.js.map +1 -0
  159. package/package.json +6 -3
  160. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
  161. package/prisma/schema.prisma +465 -387
  162. package/src/index.ts +12 -1
  163. package/src/lib/config/cors.ts +0 -1
  164. package/src/lib/fileUpload.ts +2 -4
  165. package/src/lib/pusher.ts +7 -1
  166. package/src/models/attendance.ts +15 -0
  167. package/src/models/auth.ts +1 -0
  168. package/src/models/school.ts +0 -14
  169. package/src/models/worksheet.ts +2 -2
  170. package/src/pipelines/aiLabChat.ts +71 -79
  171. package/src/pipelines/aiNewtonChat.ts +47 -31
  172. package/src/pipelines/gradeWorksheet.ts +63 -25
  173. package/src/pipelines/pdfToWorksheet.ts +272 -34
  174. package/src/routers/assignment.ts +74 -3
  175. package/src/routers/school.ts +5 -0
  176. package/src/routers/worksheet.ts +25 -4
  177. package/src/services/announcement.ts +2 -2
  178. package/src/services/assignment.ts +18 -4
  179. package/src/services/attendance.ts +87 -2
  180. package/src/services/labChat.ts +108 -48
  181. package/src/services/message.ts +19 -0
  182. package/src/services/newtonChat.ts +46 -25
  183. package/src/services/school.ts +89 -6
  184. package/src/services/worksheet.ts +87 -28
  185. package/src/utils/generateUsername.ts +108 -0
  186. package/src/utils/inference.ts +209 -1
  187. package/src/workers/connection.ts +37 -0
  188. package/src/workers/index.ts +71 -0
  189. package/src/workers/processors/aiLabChat.ts +65 -0
  190. package/src/workers/processors/aiNewtonChat.ts +75 -0
  191. package/src/workers/processors/createRunJobWorker.ts +164 -0
  192. package/src/workers/processors/gradeWorksheet.ts +16 -0
  193. package/src/workers/processors/pdfToWorksheet.ts +17 -0
  194. package/src/workers/processors/studentProgressChat.ts +28 -0
  195. package/src/workers/queues.ts +174 -0
  196. package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
  197. package/tests/{helpers.ts → integration/helpers.ts} +2 -2
  198. package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
  199. package/tests/{routers → integration/routers}/attendance.test.ts +61 -2
  200. package/tests/{routers → integration/routers}/auth.test.ts +3 -3
  201. package/tests/{routers → integration/routers}/class.test.ts +3 -17
  202. package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
  203. package/tests/{routers → integration/routers}/event.test.ts +1 -1
  204. package/tests/{routers → integration/routers}/message.test.ts +1 -1
  205. package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
  206. package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
  207. package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
  208. package/tests/integration/routers/worksheet.test.ts +448 -0
  209. package/tests/{setup.ts → integration/setup.ts} +4 -4
  210. package/tests/{lib → unit/lib}/cors.test.ts +1 -1
  211. package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
  212. package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
  213. package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
  214. package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
  215. package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
  216. package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
  217. package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
  218. package/tests/{utils → unit/utils}/inference.test.ts +6 -6
  219. package/tests/{utils → unit/utils}/logger.test.ts +1 -1
  220. package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
  221. package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
  222. package/vitest.config.ts +11 -3
  223. package/vitest.unit.config.ts +10 -10
  224. package/tests/routers/worksheet.test.ts +0 -194
  225. package/tests/startup.test.ts +0 -5
  226. package/tests/worksheet.test.ts +0 -181
  227. /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
  228. /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
  229. /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
  230. /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
  231. /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
  232. /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
  233. /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
  234. /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
  235. /package/tests/{routers → integration/routers}/search.test.ts +0 -0
  236. /package/tests/{routers → integration/routers}/section.test.ts +0 -0
  237. /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
@@ -2,7 +2,7 @@
2
2
  * Grade worksheet pipeline – AI-powered grading of worksheet responses.
3
3
  * Grades questions via inference API, broadcasts status via Pusher (pending/completed/failed/cancelled).
4
4
  */
5
- import { GenerationStatus, WorksheetQuestionType } from "@prisma/client";
5
+ import { JobStatus, WorksheetQuestionType } from "@prisma/client";
6
6
  import { prisma } from "../lib/prisma.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import z from "zod";
@@ -113,11 +113,11 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
113
113
  const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
114
114
  where: { id: studentQuestionProgress.id, status: {
115
115
  not: {
116
- in: ['CANCELLED'],
116
+ in: [JobStatus.CANCELLED],
117
117
  },
118
118
  } },
119
119
  data: {
120
- status: GenerationStatus.COMPLETED,
120
+ status: JobStatus.COMPLETED,
121
121
  isCorrect: (apiResponse as { isCorrect: boolean }).isCorrect,
122
122
  points: (apiResponse as { points: number }).points,
123
123
  markschemeState: (apiResponse as {
@@ -143,13 +143,23 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
143
143
  return updatedStudentQuestionProgress;
144
144
  } catch (error) {
145
145
  logger.error('Failed to grade worksheet response', { error, worksheetResponseId });
146
+ const { count } = await prisma.studentQuestionProgress.updateMany({
147
+ where: {
148
+ id: studentQuestionProgress.id,
149
+ status: { not: JobStatus.CANCELLED },
150
+ },
151
+ data: { status: JobStatus.FAILED },
152
+ });
153
+ if (count === 0) {
154
+ logger.info('Skipping failed status update for cancelled worksheet question', {
155
+ worksheetResponseId,
156
+ worksheetQuestionProgressId,
157
+ });
158
+ return;
159
+ }
146
160
  pusher.trigger(worksheetChannel(worksheetResponse.id), `set-failed`, {
147
161
  id: studentQuestionProgress.id,
148
162
  });
149
- await prisma.studentQuestionProgress.update({
150
- where: { id: studentQuestionProgress.id },
151
- data: { status: GenerationStatus.FAILED },
152
- });
153
163
  throw error;
154
164
  }
155
165
  }
@@ -160,7 +170,7 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
160
170
  * @returns updated worksheet response
161
171
  */
162
172
 
163
- const DO_NOT_INFERENCE_STATUSES = [GenerationStatus.CANCELLED, GenerationStatus.PENDING, GenerationStatus.COMPLETED];
173
+ const DO_NOT_INFERENCE_STATUSES = [JobStatus.CANCELLED, JobStatus.PENDING, JobStatus.COMPLETED];
164
174
 
165
175
  export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
166
176
  logger.info('Grading worksheet response', { worksheetResponseId });
@@ -200,17 +210,20 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
200
210
  for (const response of worksheetResponse.responses) {
201
211
  logger.info('Grading question', { questionId: response.questionId });
202
212
 
203
- const studentQuestionProgress = await prisma.studentQuestionProgress.update({
204
- where: { id: response.id, status: {
205
- not: {
206
- in: DO_NOT_INFERENCE_STATUSES,
207
- }
208
- } },
209
- data: { status: GenerationStatus.PENDING },
213
+ const { count } = await prisma.studentQuestionProgress.updateMany({
214
+ where: {
215
+ id: response.id,
216
+ status: {
217
+ not: {
218
+ in: DO_NOT_INFERENCE_STATUSES,
219
+ },
220
+ },
221
+ },
222
+ data: { status: JobStatus.PENDING },
210
223
  });
211
224
 
212
- if (studentQuestionProgress.status !== GenerationStatus.PENDING) {
213
- return;
225
+ if (count === 0) {
226
+ continue;
214
227
  }
215
228
 
216
229
  await gradeWorksheetQuestion(worksheetResponseId, response.id);
@@ -233,7 +246,7 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
233
246
  }
234
247
  const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
235
248
  where: { id: worksheetQuestionProgressId },
236
- data: { status: GenerationStatus.CANCELLED },
249
+ data: { status: JobStatus.CANCELLED },
237
250
  });
238
251
 
239
252
  await removeAllPreviousAIComments(worksheetQuestionProgressId);
@@ -255,26 +268,51 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
255
268
  },
256
269
  });
257
270
 
258
- await removeAllPreviousAIComments(worksheetQuestionProgressId);
259
-
260
271
  if (!worksheetResponse) {
261
272
  logger.error('Worksheet response not found');
262
273
  throw new Error('Worksheet response not found');
263
274
  }
264
275
 
265
- const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
276
+ const { count } = await prisma.studentQuestionProgress.updateMany({
277
+ where: {
278
+ id: worksheetQuestionProgressId,
279
+ status: { not: JobStatus.CANCELLED },
280
+ },
281
+ data: { status: JobStatus.PENDING },
282
+ });
283
+
284
+ if (count === 0) {
285
+ logger.info('Skipping regrade for cancelled worksheet question', {
286
+ worksheetResponseId,
287
+ worksheetQuestionProgressId,
288
+ });
289
+ return null;
290
+ }
291
+
292
+ const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.findUnique({
266
293
  where: { id: worksheetQuestionProgressId },
267
- data: { status: GenerationStatus.PENDING },
268
294
  });
269
295
 
296
+ await removeAllPreviousAIComments(worksheetQuestionProgressId);
297
+
270
298
  await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
271
299
 
272
300
  return updatedStudentQuestionProgress;
273
301
  } catch (error) {
274
- await prisma.studentQuestionProgress.update({
275
- where: { id: worksheetQuestionProgressId },
276
- data: { status: GenerationStatus.FAILED },
302
+ const { count } = await prisma.studentQuestionProgress.updateMany({
303
+ where: {
304
+ id: worksheetQuestionProgressId,
305
+ status: { not: JobStatus.CANCELLED },
306
+ },
307
+ data: { status: JobStatus.FAILED },
277
308
  });
309
+ if (count === 0) {
310
+ logger.info('Skipping failed status update for cancelled worksheet question regrade', {
311
+ worksheetResponseId,
312
+ worksheetQuestionProgressId,
313
+ });
314
+ return null;
315
+ }
278
316
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
279
317
  where: { id: worksheetResponseId, },
280
318
  include: {
@@ -1,12 +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";
18
+ import { JobStatus, Prisma } from "@prisma/client";
7
19
  import { prisma } from "../lib/prisma.js";
8
20
  import { inference } from "../utils/inference.js";
9
21
  import { logger } from "../utils/logger.js";
22
+ import { pusher, worksheetJobChannel } from "../lib/pusher.js";
10
23
 
11
24
  /** Restrict remote fetches to Google-hosted object URLs. */
12
25
  function isProbablyGoogleCloudUrl(url: string): boolean {
@@ -41,6 +54,61 @@ function sanitizeUrlForLogs(url: string): string {
41
54
  }
42
55
  }
43
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
+
44
112
  /** LLM output shape (validated by inference). */
45
113
  const worksheetDraftSchema = z.object({
46
114
  title: z.string(),
@@ -104,20 +172,74 @@ const schemaInstructions = `Return ONLY valid JSON matching this schema:
104
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.
105
173
  If no answer is provided for a multiple-choice or true/false question, determine the answer from the options.`;
106
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
+
107
184
  /**
108
185
  * Sends a PDF URL to the model for remote retrieval, extracts a worksheet via
109
- * structured inference, and inserts worksheet + questions in one DB transaction.
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`.
110
192
  */
111
- export async function pdfToWorksheet(gcsUrl: string, classId: string) {
112
- const sanitizedUrl = sanitizeUrlForLogs(gcsUrl);
193
+ export async function pdfToWorksheet(
194
+ gcsUrl: string,
195
+ classId: string,
196
+ options: PdfToWorksheetOptions = {},
197
+ ) {
198
+ const sourceUrl = sanitizeUrlForLogs(gcsUrl);
199
+ const placeholderWorksheetId = options.worksheetId;
200
+
201
+ await emitStatus(placeholderWorksheetId, JobStatus.PENDING);
113
202
 
114
203
  if (!isProbablyGoogleCloudUrl(gcsUrl)) {
115
204
  logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
116
- sanitizedUrl,
205
+ sanitizedUrl: sourceUrl,
117
206
  });
207
+ await markWorksheetFailed(
208
+ placeholderWorksheetId,
209
+ "Only Google Cloud Storage URLs are supported",
210
+ );
118
211
  throw new Error("Only Google Cloud Storage URLs are supported");
119
212
  }
120
213
 
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 } },
220
+ });
221
+
222
+ if (existingForSource && existingForSource.id !== placeholderWorksheetId) {
223
+ logger.info("pdfToWorksheet: reusing existing worksheet for source PDF", {
224
+ worksheetId: existingForSource.id,
225
+ classId,
226
+ sourceUrl,
227
+ });
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;
241
+ }
242
+
121
243
  const filename = getFilenameFromUrl(gcsUrl);
122
244
  const prompt = `Read the attached PDF and convert it into a single worksheet JSON object.\n\n${schemaInstructions}`;
123
245
 
@@ -132,46 +254,162 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
132
254
  },
133
255
  ];
134
256
 
135
- const draft = await inference<WorksheetDraft>(
136
- input,
137
- worksheetDraftSchema,
138
- {
139
- reason: 'pdf_to_worksheet_draft',
140
- metadata: {
141
- classId,
142
- filename,
143
- },
144
- }
145
- );
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
+ }
146
275
 
147
276
  if (!draft) {
148
277
  logger.warn("pdfToWorksheet: no model output", { filename });
278
+ await markWorksheetFailed(
279
+ placeholderWorksheetId,
280
+ "Model returned no parseable worksheet output",
281
+ );
149
282
  return null;
150
283
  }
151
284
 
152
- return prisma.$transaction(async (tx) => {
153
- const worksheet = await tx.worksheet.create({
154
- data: { classId, name: draft.title },
155
- });
285
+ try {
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
+ });
156
305
 
157
- if (draft.questions.length > 0) {
158
- await tx.worksheetQuestion.createMany({
159
- data: draft.questions.map((question) => ({
160
- worksheetId: worksheet.id,
161
- type: question.type,
162
- question: question.question,
163
- answer: question.answer,
164
- points: Math.round(question.points ?? 0),
165
- options: question.options ?? [],
166
- markScheme: (question.markScheme ?? []).map((item) => ({
167
- ...item,
168
- points: Math.round(item.points),
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
+ }
314
+
315
+ await tx.worksheetQuestion.createMany({
316
+ data: draft!.questions.map((question) => ({
317
+ worksheetId: created.id,
318
+ type: question.type,
319
+ question: question.question,
320
+ answer: question.answer,
321
+ points: Math.round(question.points ?? 0),
322
+ options: question.options ?? [],
323
+ markScheme: (question.markScheme ?? []).map((item) => ({
324
+ ...item,
325
+ points: Math.round(item.points),
326
+ })),
327
+ order: Math.round(question.order),
169
328
  })),
170
- order: Math.round(question.order),
171
- })),
329
+ });
330
+ }
331
+
332
+ return tx.worksheet.update({
333
+ where: { id: created.id },
334
+ data: { status: JobStatus.COMPLETED },
172
335
  });
173
- }
336
+ });
337
+
338
+ await emitStatus(placeholderWorksheetId, JobStatus.COMPLETED, {
339
+ name: worksheet.name,
340
+ questionCount: draft.questions.length,
341
+ });
174
342
 
175
343
  return worksheet;
344
+ } catch (error) {
345
+ if (
346
+ error instanceof Prisma.PrismaClientKnownRequestError &&
347
+ error.code === "P2002"
348
+ ) {
349
+ const worksheet = await prisma.worksheet.findUnique({
350
+ where: {
351
+ classId_sourceUrl: {
352
+ classId,
353
+ sourceUrl,
354
+ },
355
+ },
356
+ });
357
+
358
+ if (worksheet) {
359
+ logger.info("pdfToWorksheet: reused worksheet created by concurrent retry", {
360
+ worksheetId: worksheet.id,
361
+ classId,
362
+ sourceUrl,
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
+
376
+ return worksheet;
377
+ }
378
+ }
379
+
380
+ const message = error instanceof Error ? error.message : String(error);
381
+ await markWorksheetFailed(placeholderWorksheetId, message);
382
+ throw error;
383
+ }
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,
176
412
  });
413
+
414
+ await emitStatus(worksheetId, JobStatus.FAILED);
177
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: { id: true },
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: { id: true },
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
 
@@ -104,6 +104,11 @@ export const schoolRouter = createTRPCRouter({
104
104
  schoolId: z.string(),
105
105
  email: z.string().email(),
106
106
  role: schoolRoleSchema,
107
+ username: z
108
+ .string()
109
+ .min(3, "Username must be at least 3 characters")
110
+ .optional(),
111
+ displayName: z.string().min(1).optional(),
107
112
  })
108
113
  )
109
114
  .mutation(({ ctx, input }) => createInvite(ctx.user!.id, input)),
@@ -127,8 +127,18 @@ export const worksheetRouter = createTRPCRouter({
127
127
  ),
128
128
 
129
129
  createFromPdf: protectedTeacherProcedure
130
- .input(z.object({ classId: z.string(), url: z.string().url() }))
131
- .mutation(({ input }) => createWorksheetFromPdf(input.classId, input.url)),
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(z.object({ classId: z.string(), fileId: z.string() }))
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(input.classId, input.fileId, ctx.user!.id)
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