@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.
Files changed (141) 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 +3 -2
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/lib/pusher.d.ts +7 -1
  7. package/dist/lib/pusher.d.ts.map +1 -1
  8. package/dist/lib/pusher.js +9 -3
  9. package/dist/lib/pusher.js.map +1 -1
  10. package/dist/models/attendance.d.ts +16 -7
  11. package/dist/models/attendance.d.ts.map +1 -1
  12. package/dist/models/attendance.js +17 -2
  13. package/dist/models/attendance.js.map +1 -1
  14. package/dist/models/conversation.d.ts +33 -17
  15. package/dist/models/conversation.d.ts.map +1 -1
  16. package/dist/models/conversation.js +50 -2
  17. package/dist/models/conversation.js.map +1 -1
  18. package/dist/pipelines/aiLabChat.d.ts +20 -9
  19. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  20. package/dist/pipelines/aiLabChat.js +62 -74
  21. package/dist/pipelines/aiLabChat.js.map +1 -1
  22. package/dist/pipelines/aiNewtonChat.d.ts +13 -4
  23. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.js +39 -27
  25. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  26. package/dist/pipelines/pdfToWorksheet.d.ts +31 -2
  27. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  28. package/dist/pipelines/pdfToWorksheet.js +160 -28
  29. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  30. package/dist/routers/_app.d.ts +48 -4
  31. package/dist/routers/_app.d.ts.map +1 -1
  32. package/dist/routers/assignment.d.ts.map +1 -1
  33. package/dist/routers/assignment.js +64 -4
  34. package/dist/routers/assignment.js.map +1 -1
  35. package/dist/routers/conversation.d.ts +15 -0
  36. package/dist/routers/conversation.d.ts.map +1 -1
  37. package/dist/routers/conversation.js +6 -3
  38. package/dist/routers/conversation.js.map +1 -1
  39. package/dist/routers/labChat.d.ts +1 -0
  40. package/dist/routers/labChat.d.ts.map +1 -1
  41. package/dist/routers/message.d.ts +3 -0
  42. package/dist/routers/message.d.ts.map +1 -1
  43. package/dist/routers/newtonChat.d.ts +1 -0
  44. package/dist/routers/newtonChat.d.ts.map +1 -1
  45. package/dist/routers/worksheet.d.ts +4 -2
  46. package/dist/routers/worksheet.d.ts.map +1 -1
  47. package/dist/routers/worksheet.js +16 -6
  48. package/dist/routers/worksheet.js.map +1 -1
  49. package/dist/services/announcement.js +4 -4
  50. package/dist/services/announcement.js.map +1 -1
  51. package/dist/services/assignment.d.ts.map +1 -1
  52. package/dist/services/assignment.js +16 -6
  53. package/dist/services/assignment.js.map +1 -1
  54. package/dist/services/attendance.d.ts.map +1 -1
  55. package/dist/services/attendance.js +68 -4
  56. package/dist/services/attendance.js.map +1 -1
  57. package/dist/services/conversation.d.ts +16 -0
  58. package/dist/services/conversation.d.ts.map +1 -1
  59. package/dist/services/conversation.js +22 -3
  60. package/dist/services/conversation.js.map +1 -1
  61. package/dist/services/labChat.d.ts +8 -1
  62. package/dist/services/labChat.d.ts.map +1 -1
  63. package/dist/services/labChat.js +101 -42
  64. package/dist/services/labChat.js.map +1 -1
  65. package/dist/services/message.d.ts +3 -0
  66. package/dist/services/message.d.ts.map +1 -1
  67. package/dist/services/message.js +8 -2
  68. package/dist/services/message.js.map +1 -1
  69. package/dist/services/newtonChat.d.ts +1 -0
  70. package/dist/services/newtonChat.d.ts.map +1 -1
  71. package/dist/services/newtonChat.js +40 -17
  72. package/dist/services/newtonChat.js.map +1 -1
  73. package/dist/services/worksheet.d.ts +15 -6
  74. package/dist/services/worksheet.d.ts.map +1 -1
  75. package/dist/services/worksheet.js +58 -12
  76. package/dist/services/worksheet.js.map +1 -1
  77. package/dist/utils/inference.d.ts +50 -1
  78. package/dist/utils/inference.d.ts.map +1 -1
  79. package/dist/utils/inference.js +167 -2
  80. package/dist/utils/inference.js.map +1 -1
  81. package/dist/workers/index.js +13 -3
  82. package/dist/workers/index.js.map +1 -1
  83. package/dist/workers/processors/aiLabChat.d.ts +7 -0
  84. package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
  85. package/dist/workers/processors/aiLabChat.js +52 -0
  86. package/dist/workers/processors/aiLabChat.js.map +1 -0
  87. package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
  88. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
  89. package/dist/workers/processors/aiNewtonChat.js +57 -0
  90. package/dist/workers/processors/aiNewtonChat.js.map +1 -0
  91. package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
  92. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
  93. package/dist/workers/processors/createRunJobWorker.js +118 -0
  94. package/dist/workers/processors/createRunJobWorker.js.map +1 -0
  95. package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
  96. package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
  97. package/dist/workers/processors/gradeWorksheet.js +19 -0
  98. package/dist/workers/processors/gradeWorksheet.js.map +1 -0
  99. package/dist/workers/processors/pdfToWorksheet.d.ts +5 -6
  100. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  101. package/dist/workers/processors/pdfToWorksheet.js +15 -95
  102. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  103. package/dist/workers/processors/studentProgressChat.d.ts +8 -0
  104. package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
  105. package/dist/workers/processors/studentProgressChat.js +27 -0
  106. package/dist/workers/processors/studentProgressChat.js.map +1 -0
  107. package/dist/workers/queues.d.ts +20 -4
  108. package/dist/workers/queues.d.ts.map +1 -1
  109. package/dist/workers/queues.js +3 -3
  110. package/dist/workers/queues.js.map +1 -1
  111. package/package.json +3 -3
  112. package/src/index.ts +12 -1
  113. package/src/lib/config/cors.ts +1 -0
  114. package/src/lib/pusher.ts +7 -1
  115. package/src/models/attendance.ts +15 -0
  116. package/src/models/conversation.ts +54 -0
  117. package/src/pipelines/aiLabChat.ts +70 -78
  118. package/src/pipelines/aiNewtonChat.ts +47 -31
  119. package/src/pipelines/pdfToWorksheet.ts +224 -32
  120. package/src/routers/assignment.ts +74 -3
  121. package/src/routers/conversation.ts +7 -0
  122. package/src/routers/worksheet.ts +25 -4
  123. package/src/services/announcement.ts +2 -2
  124. package/src/services/assignment.ts +17 -4
  125. package/src/services/attendance.ts +87 -2
  126. package/src/services/conversation.ts +30 -0
  127. package/src/services/labChat.ts +106 -46
  128. package/src/services/message.ts +6 -0
  129. package/src/services/newtonChat.ts +45 -25
  130. package/src/services/worksheet.ts +71 -10
  131. package/src/utils/inference.ts +209 -1
  132. package/src/workers/index.ts +11 -1
  133. package/src/workers/processors/aiLabChat.ts +65 -0
  134. package/src/workers/processors/aiNewtonChat.ts +75 -0
  135. package/src/workers/processors/createRunJobWorker.ts +164 -0
  136. package/src/workers/processors/gradeWorksheet.ts +16 -0
  137. package/src/workers/processors/pdfToWorksheet.ts +12 -114
  138. package/src/workers/processors/studentProgressChat.ts +28 -0
  139. package/src/workers/queues.ts +20 -5
  140. package/tests/integration/routers/attendance.test.ts +60 -1
  141. 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 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`.
111
192
  */
112
- export async function pdfToWorksheet(gcsUrl: string, classId: string) {
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
- const existingWorksheet = await prisma.worksheet.findUnique({
123
- where: {
124
- classId_sourceUrl: {
125
- classId,
126
- sourceUrl,
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 (existingWorksheet) {
222
+ if (existingForSource && existingForSource.id !== placeholderWorksheetId) {
132
223
  logger.info("pdfToWorksheet: reusing existing worksheet for source PDF", {
133
- worksheetId: existingWorksheet.id,
224
+ worksheetId: existingForSource.id,
134
225
  classId,
135
226
  sourceUrl,
136
227
  });
137
- return existingWorksheet;
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
- const draft = await inference<WorksheetDraft>(
155
- input,
156
- worksheetDraftSchema,
157
- {
158
- reason: 'pdf_to_worksheet_draft',
159
- metadata: {
160
- classId,
161
- filename,
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
- return await prisma.$transaction(async (tx) => {
173
- const worksheet = await tx.worksheet.create({
174
- data: { classId, name: draft.title, sourceUrl },
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.questions.map((question) => ({
180
- worksheetId: worksheet.id,
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: { 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
 
@@ -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({
@@ -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
@@ -121,8 +121,8 @@ export async function createAnnouncementRecord(
121
121
 
122
122
  sendToMultiple({
123
123
  receiverIds: classData.students.map((s) => s.id),
124
- title: `🔔 Announcement for ${classData.name}`,
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 for ${classData.name}`,
316
- content: `The assignment "${title}" has been created in ${classData.name}.\nDue date: ${new Date(dueDate).toLocaleDateString()}.\n[Link to assignment](/class/${classId}/assignments/${assignment.id})`,
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
- gradeWorksheetPipeline(wr.id);
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
- return createAttendance({
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
- return updateAttendance(existing.id, {
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
  }