@studious-lms/server 1.4.7 → 1.5.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 (201) hide show
  1. package/.github/workflows/npm.yml +28 -0
  2. package/dist/lib/config/cors.d.ts.map +1 -1
  3. package/dist/lib/config/cors.js +2 -4
  4. package/dist/lib/config/cors.js.map +1 -1
  5. package/dist/lib/fileUpload.d.ts.map +1 -1
  6. package/dist/lib/fileUpload.js +4 -5
  7. package/dist/lib/fileUpload.js.map +1 -1
  8. package/dist/models/assignment.d.ts +32 -0
  9. package/dist/models/assignment.d.ts.map +1 -1
  10. package/dist/models/assignment.js +111 -2
  11. package/dist/models/assignment.js.map +1 -1
  12. package/dist/models/auth.d.ts +1 -0
  13. package/dist/models/auth.d.ts.map +1 -1
  14. package/dist/models/auth.js +3 -2
  15. package/dist/models/auth.js.map +1 -1
  16. package/dist/models/comment.d.ts +0 -5
  17. package/dist/models/comment.d.ts.map +1 -1
  18. package/dist/models/conversation.d.ts +2 -1
  19. package/dist/models/conversation.d.ts.map +1 -1
  20. package/dist/models/labChat.d.ts +2 -1
  21. package/dist/models/labChat.d.ts.map +1 -1
  22. package/dist/models/message.d.ts +6 -3
  23. package/dist/models/message.d.ts.map +1 -1
  24. package/dist/models/school.d.ts +0 -22
  25. package/dist/models/school.d.ts.map +1 -1
  26. package/dist/models/school.js +2 -14
  27. package/dist/models/school.js.map +1 -1
  28. package/dist/models/worksheet.d.ts +174 -95
  29. package/dist/models/worksheet.d.ts.map +1 -1
  30. package/dist/models/worksheet.js +38 -5
  31. package/dist/models/worksheet.js.map +1 -1
  32. package/dist/pipelines/aiLabChat.js +6 -6
  33. package/dist/pipelines/aiLabChat.js.map +1 -1
  34. package/dist/pipelines/gradeWorksheet.d.ts +5 -3
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +60 -24
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/pipelines/pdfToWorksheet.d.ts +3 -0
  39. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  40. package/dist/pipelines/pdfToWorksheet.js +65 -24
  41. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  42. package/dist/routers/_app.d.ts +232 -48
  43. package/dist/routers/_app.d.ts.map +1 -1
  44. package/dist/routers/announcement.d.ts +0 -4
  45. package/dist/routers/announcement.d.ts.map +1 -1
  46. package/dist/routers/assignment.d.ts +35 -8
  47. package/dist/routers/assignment.d.ts.map +1 -1
  48. package/dist/routers/assignment.js +10 -3
  49. package/dist/routers/assignment.js.map +1 -1
  50. package/dist/routers/auth.d.ts +1 -0
  51. package/dist/routers/auth.d.ts.map +1 -1
  52. package/dist/routers/class.d.ts +19 -0
  53. package/dist/routers/class.d.ts.map +1 -1
  54. package/dist/routers/comment.d.ts +0 -3
  55. package/dist/routers/comment.d.ts.map +1 -1
  56. package/dist/routers/conversation.d.ts +2 -1
  57. package/dist/routers/conversation.d.ts.map +1 -1
  58. package/dist/routers/labChat.d.ts +2 -1
  59. package/dist/routers/labChat.d.ts.map +1 -1
  60. package/dist/routers/school.d.ts +6 -0
  61. package/dist/routers/school.d.ts.map +1 -1
  62. package/dist/routers/school.js +7 -2
  63. package/dist/routers/school.js.map +1 -1
  64. package/dist/routers/worksheet.d.ts +51 -7
  65. package/dist/routers/worksheet.d.ts.map +1 -1
  66. package/dist/routers/worksheet.js +10 -3
  67. package/dist/routers/worksheet.js.map +1 -1
  68. package/dist/services/assignment.d.ts +30 -0
  69. package/dist/services/assignment.d.ts.map +1 -1
  70. package/dist/services/assignment.js +37 -3
  71. package/dist/services/assignment.js.map +1 -1
  72. package/dist/services/auth.d.ts +1 -0
  73. package/dist/services/auth.d.ts.map +1 -1
  74. package/dist/services/class.d.ts +19 -0
  75. package/dist/services/class.d.ts.map +1 -1
  76. package/dist/services/class.js +37 -19
  77. package/dist/services/class.js.map +1 -1
  78. package/dist/services/comment.d.ts +0 -3
  79. package/dist/services/comment.d.ts.map +1 -1
  80. package/dist/services/conversation.d.ts +2 -1
  81. package/dist/services/conversation.d.ts.map +1 -1
  82. package/dist/services/labChat.d.ts +2 -1
  83. package/dist/services/labChat.d.ts.map +1 -1
  84. package/dist/services/labChat.js +6 -6
  85. package/dist/services/labChat.js.map +1 -1
  86. package/dist/services/message.d.ts.map +1 -1
  87. package/dist/services/message.js +14 -2
  88. package/dist/services/message.js.map +1 -1
  89. package/dist/services/school.d.ts +6 -0
  90. package/dist/services/school.d.ts.map +1 -1
  91. package/dist/services/school.js +71 -6
  92. package/dist/services/school.js.map +1 -1
  93. package/dist/services/worksheet.d.ts +46 -7
  94. package/dist/services/worksheet.d.ts.map +1 -1
  95. package/dist/services/worksheet.js +52 -20
  96. package/dist/services/worksheet.js.map +1 -1
  97. package/dist/utils/generateUsername.d.ts +8 -0
  98. package/dist/utils/generateUsername.d.ts.map +1 -0
  99. package/dist/utils/generateUsername.js +103 -0
  100. package/dist/utils/generateUsername.js.map +1 -0
  101. package/dist/workers/connection.d.ts +3 -0
  102. package/dist/workers/connection.d.ts.map +1 -0
  103. package/dist/workers/connection.js +33 -0
  104. package/dist/workers/connection.js.map +1 -0
  105. package/dist/workers/index.d.ts +10 -0
  106. package/dist/workers/index.d.ts.map +1 -0
  107. package/dist/workers/index.js +53 -0
  108. package/dist/workers/index.js.map +1 -0
  109. package/dist/workers/processors/pdfToWorksheet.d.ts +9 -0
  110. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
  111. package/dist/workers/processors/pdfToWorksheet.js +100 -0
  112. package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
  113. package/dist/workers/queues.d.ts +69 -0
  114. package/dist/workers/queues.d.ts.map +1 -0
  115. package/dist/workers/queues.js +119 -0
  116. package/dist/workers/queues.js.map +1 -0
  117. package/package.json +4 -1
  118. package/prisma/migrations/20260504071500_school_memberships/migration.sql +1 -1
  119. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
  120. package/prisma/schema.prisma +465 -387
  121. package/src/lib/config/cors.ts +0 -2
  122. package/src/lib/fileUpload.ts +2 -4
  123. package/src/models/assignment.ts +132 -0
  124. package/src/models/auth.ts +1 -0
  125. package/src/models/school.ts +0 -14
  126. package/src/models/worksheet.ts +56 -2
  127. package/src/pipelines/aiLabChat.ts +4 -4
  128. package/src/pipelines/gradeWorksheet.ts +63 -25
  129. package/src/pipelines/pdfToWorksheet.ts +68 -22
  130. package/src/routers/assignment.ts +19 -0
  131. package/src/routers/school.ts +5 -0
  132. package/src/routers/worksheet.ts +18 -0
  133. package/src/services/assignment.ts +47 -1
  134. package/src/services/class.ts +41 -17
  135. package/src/services/labChat.ts +4 -4
  136. package/src/services/message.ts +13 -0
  137. package/src/services/school.ts +89 -6
  138. package/src/services/worksheet.ts +62 -18
  139. package/src/utils/generateUsername.ts +108 -0
  140. package/src/workers/connection.ts +37 -0
  141. package/src/workers/index.ts +61 -0
  142. package/src/workers/processors/pdfToWorksheet.ts +119 -0
  143. package/src/workers/queues.ts +159 -0
  144. package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
  145. package/tests/{helpers.ts → integration/helpers.ts} +2 -2
  146. package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
  147. package/tests/{routers → integration/routers}/attendance.test.ts +1 -1
  148. package/tests/{routers → integration/routers}/auth.test.ts +3 -3
  149. package/tests/{routers → integration/routers}/class.test.ts +3 -17
  150. package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
  151. package/tests/{routers → integration/routers}/event.test.ts +1 -1
  152. package/tests/{routers → integration/routers}/message.test.ts +1 -1
  153. package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
  154. package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
  155. package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
  156. package/tests/integration/routers/worksheet.test.ts +448 -0
  157. package/tests/{setup.ts → integration/setup.ts} +4 -4
  158. package/tests/{lib → unit/lib}/cors.test.ts +1 -1
  159. package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
  160. package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
  161. package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
  162. package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
  163. package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
  164. package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
  165. package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
  166. package/tests/{utils → unit/utils}/inference.test.ts +6 -6
  167. package/tests/{utils → unit/utils}/logger.test.ts +1 -1
  168. package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
  169. package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
  170. package/vitest.config.ts +11 -3
  171. package/vitest.unit.config.ts +10 -10
  172. package/dist/lib/notificationHandler.d.ts +0 -25
  173. package/dist/lib/notificationHandler.d.ts.map +0 -1
  174. package/dist/lib/notificationHandler.js +0 -32
  175. package/dist/lib/notificationHandler.js.map +0 -1
  176. package/dist/server/pipelines/aiLabChat.d.ts +0 -21
  177. package/dist/server/pipelines/aiLabChat.d.ts.map +0 -1
  178. package/dist/server/pipelines/aiLabChat.js +0 -456
  179. package/dist/server/pipelines/aiLabChat.js.map +0 -1
  180. package/dist/server/pipelines/aiNewtonChat.d.ts +0 -30
  181. package/dist/server/pipelines/aiNewtonChat.d.ts.map +0 -1
  182. package/dist/server/pipelines/aiNewtonChat.js +0 -285
  183. package/dist/server/pipelines/aiNewtonChat.js.map +0 -1
  184. package/dist/server/pipelines/gradeWorksheet.d.ts +0 -30
  185. package/dist/server/pipelines/gradeWorksheet.d.ts.map +0 -1
  186. package/dist/server/pipelines/gradeWorksheet.js +0 -248
  187. package/dist/server/pipelines/gradeWorksheet.js.map +0 -1
  188. package/tests/routers/worksheet.test.ts +0 -194
  189. package/tests/startup.test.ts +0 -5
  190. package/tests/worksheet.test.ts +0 -181
  191. /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
  192. /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
  193. /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
  194. /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
  195. /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
  196. /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
  197. /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
  198. /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
  199. /package/tests/{routers → integration/routers}/search.test.ts +0 -0
  200. /package/tests/{routers → integration/routers}/section.test.ts +0 -0
  201. /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
@@ -8,8 +8,6 @@ type CorsEnv = {
8
8
  const productionOrigins = [
9
9
  'https://www.studious.sh',
10
10
  'https://studious.sh',
11
- 'https://dev.studious.sh',
12
- 'https://www.dev.studious.sh',
13
11
  ];
14
12
 
15
13
  const nonProductionOrigins = [
@@ -189,6 +189,7 @@ export async function createDirectUploadFile(
189
189
  uploadUrl,
190
190
  uploadExpiresAt,
191
191
  uploadSessionId,
192
+ uploadedAt: null,
192
193
  user: {
193
194
  connect: { id: userId }
194
195
  },
@@ -292,6 +293,7 @@ export async function confirmDirectUpload(
292
293
  const updateData: any = {
293
294
  uploadStatus: actualUploadSuccess ? 'COMPLETED' : 'FAILED',
294
295
  uploadProgress: actualUploadSuccess ? 100 : 0,
296
+ uploadedAt: actualUploadSuccess ? new Date() : null,
295
297
  };
296
298
 
297
299
  if (!actualUploadSuccess && actualErrorMessage) {
@@ -299,10 +301,6 @@ export async function confirmDirectUpload(
299
301
  updateData.uploadRetryCount = { increment: 1 };
300
302
  }
301
303
 
302
- if (actualUploadSuccess) {
303
- updateData.uploadedAt = new Date();
304
- }
305
-
306
304
  await prisma.file.update({
307
305
  where: { id: fileId },
308
306
  data: updateData
@@ -2,6 +2,7 @@
2
2
  * Assignment model – assignments, submissions, attachments.
3
3
  */
4
4
  import { prisma } from "../lib/prisma.js";
5
+ import { duplicateWorksheetWithClient } from "./worksheet.js";
5
6
  import {
6
7
  classMemberOrSchoolAdminWhere,
7
8
  classTeacherOrSchoolAdminWhere,
@@ -214,6 +215,137 @@ export function findAssignmentWithClassForTeacher(assignmentId: string, teacherI
214
215
  });
215
216
  }
216
217
 
218
+ /**
219
+ * Clone an assignment into another class with a new title: copies settings, duplicates linked
220
+ * worksheets into the target class, clones attachment file records (same storage path),
221
+ * creates empty submissions for all students in the target class, and does not copy the
222
+ * calendar event link or per-student “assigned to” overrides.
223
+ */
224
+ export async function duplicateAssignment(
225
+ sourceAssignmentId: string,
226
+ targetClassId: string,
227
+ title: string,
228
+ duplicatingUserId: string
229
+ ) {
230
+ return prisma.$transaction(
231
+ async (tx) => {
232
+ const source = await tx.assignment.findUnique({
233
+ where: { id: sourceAssignmentId },
234
+ include: {
235
+ worksheets: { select: { id: true, name: true } },
236
+ attachments: {
237
+ select: { name: true, type: true, size: true, path: true },
238
+ },
239
+ section: { select: { id: true, name: true } },
240
+ },
241
+ });
242
+
243
+ if (!source) {
244
+ return null;
245
+ }
246
+
247
+ let targetSectionId: string | undefined;
248
+ if (source.sectionId && source.section) {
249
+ const match = await tx.section.findFirst({
250
+ where: { classId: targetClassId, name: source.section.name },
251
+ select: { id: true },
252
+ });
253
+ targetSectionId = match?.id;
254
+ }
255
+
256
+ const newWorksheetIds: string[] = [];
257
+ for (const ws of source.worksheets) {
258
+ const dup = await duplicateWorksheetWithClient(
259
+ tx,
260
+ ws.id,
261
+ targetClassId,
262
+ ws.name
263
+ );
264
+ if (dup) {
265
+ newWorksheetIds.push(dup.id);
266
+ }
267
+ }
268
+
269
+ const classData = await tx.class.findUnique({
270
+ where: { id: targetClassId },
271
+ include: { students: { select: { id: true } } },
272
+ });
273
+ if (!classData) {
274
+ return null;
275
+ }
276
+
277
+ const submissionData = classData.students.map((s) => ({
278
+ student: { connect: { id: s.id } },
279
+ }));
280
+
281
+ const created = await tx.assignment.create({
282
+ data: {
283
+ title,
284
+ instructions: source.instructions,
285
+ dueDate: source.dueDate,
286
+ teacher: { connect: { id: duplicatingUserId } },
287
+ class: { connect: { id: targetClassId } },
288
+ acceptFiles: source.acceptFiles,
289
+ acceptExtendedResponse: source.acceptExtendedResponse,
290
+ acceptWorksheet: source.acceptWorksheet,
291
+ gradeWithAI: source.gradeWithAI,
292
+ aiPolicyLevel: source.aiPolicyLevel,
293
+ graded: source.graded,
294
+ maxGrade: source.maxGrade,
295
+ weight: source.weight,
296
+ type: source.type,
297
+ inProgress: source.inProgress,
298
+ template: source.template,
299
+ ...(source.markSchemeId && {
300
+ markScheme: { connect: { id: source.markSchemeId } },
301
+ }),
302
+ ...(source.gradingBoundaryId && {
303
+ gradingBoundary: { connect: { id: source.gradingBoundaryId } },
304
+ }),
305
+ ...(targetSectionId && {
306
+ section: { connect: { id: targetSectionId } },
307
+ }),
308
+ ...(newWorksheetIds.length > 0 && {
309
+ worksheets: { connect: newWorksheetIds.map((id) => ({ id })) },
310
+ }),
311
+ submissions: { create: submissionData },
312
+ order: 0,
313
+ },
314
+ });
315
+
316
+ await tx.assignment.updateMany({
317
+ where: { classId: targetClassId, id: { not: created.id } },
318
+ data: { order: { increment: 1 } },
319
+ });
320
+ await tx.section.updateMany({
321
+ where: { classId: targetClassId },
322
+ data: { order: { increment: 1 } },
323
+ });
324
+ await tx.assignment.update({
325
+ where: { id: created.id },
326
+ data: { order: 1 },
327
+ });
328
+
329
+ for (const file of source.attachments) {
330
+ await tx.file.create({
331
+ data: {
332
+ name: file.name,
333
+ type: file.type,
334
+ size: file.size ?? 0,
335
+ path: file.path,
336
+ uploadStatus: "COMPLETED",
337
+ user: { connect: { id: duplicatingUserId } },
338
+ assignment: { connect: { id: created.id } },
339
+ },
340
+ });
341
+ }
342
+
343
+ return created;
344
+ },
345
+ { maxWait: 10000, timeout: 60000 }
346
+ );
347
+ }
348
+
217
349
  /** @returns Submissions for assignment (teacher view). */
218
350
  export function findSubmissionsByAssignmentForTeacher(assignmentId: string, teacherId: string) {
219
351
  return prisma.submission.findMany({
@@ -82,6 +82,7 @@ export function findUserById(id: string) {
82
82
  select: {
83
83
  id: true,
84
84
  username: true,
85
+ email: true,
85
86
  profile: {
86
87
  select: {
87
88
  displayName: true,
@@ -248,20 +248,6 @@ export function removeSchoolMembershipPreservingContent(schoolId: string, userId
248
248
  });
249
249
  }
250
250
 
251
- export function findUserByEmailForSchool(email: string) {
252
- return prisma.user.findFirst({
253
- where: { email },
254
- select: { id: true, email: true, username: true },
255
- });
256
- }
257
-
258
- export function findUserByIdForSchool(userId: string) {
259
- return prisma.user.findUnique({
260
- where: { id: userId },
261
- select: { id: true, email: true, username: true },
262
- });
263
- }
264
-
265
251
  export function createSchoolInvite(data: {
266
252
  schoolId: string;
267
253
  invitedById: string;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Worksheet model – worksheets, questions, submissions, student progress, grading.
3
3
  */
4
+ import type { Prisma } from "@prisma/client";
4
5
  import { prisma } from "../lib/prisma.js";
5
- import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
6
+ import type { WorksheetQuestionType, JobStatus } from "@prisma/client";
6
7
  import {
7
8
  classMemberOrSchoolAdminWhere,
8
9
  classTeacherOrSchoolAdminWhere,
@@ -115,6 +116,59 @@ export function deleteWorksheet(id: string) {
115
116
  });
116
117
  }
117
118
 
119
+ /**
120
+ * Clone a worksheet and its questions into another class with a new name.
121
+ * Does not copy student responses or assignment links.
122
+ */
123
+ export async function duplicateWorksheetWithClient(
124
+ tx: Prisma.TransactionClient,
125
+ sourceWorksheetId: string,
126
+ targetClassId: string,
127
+ name: string
128
+ ) {
129
+ const source = await tx.worksheet.findUnique({
130
+ where: { id: sourceWorksheetId },
131
+ include: {
132
+ questions: { orderBy: { order: "asc" } },
133
+ },
134
+ });
135
+
136
+ if (!source) {
137
+ return null;
138
+ }
139
+
140
+ const created = await tx.worksheet.create({
141
+ data: { classId: targetClassId, name },
142
+ });
143
+
144
+ if (source.questions.length > 0) {
145
+ await tx.worksheetQuestion.createMany({
146
+ data: source.questions.map((q) => ({
147
+ worksheetId: created.id,
148
+ type: q.type,
149
+ question: q.question,
150
+ answer: q.answer,
151
+ points: q.points,
152
+ options: q.options ?? undefined,
153
+ markScheme: q.markScheme ?? undefined,
154
+ order: q.order,
155
+ })),
156
+ });
157
+ }
158
+
159
+ return created;
160
+ }
161
+
162
+ export async function duplicateWorksheet(
163
+ sourceWorksheetId: string,
164
+ targetClassId: string,
165
+ name: string
166
+ ) {
167
+ return prisma.$transaction((tx) =>
168
+ duplicateWorksheetWithClient(tx, sourceWorksheetId, targetClassId, name)
169
+ );
170
+ }
171
+
118
172
  /** @returns Questions for a worksheet. */
119
173
  export function findQuestionsByWorksheetId(worksheetId: string) {
120
174
  return prisma.worksheetQuestion.findMany({
@@ -409,7 +463,7 @@ export function updateStudentQuestionProgress(
409
463
  response?: string;
410
464
  isCorrect?: boolean;
411
465
  points?: number;
412
- status?: GenerationStatus;
466
+ status?: JobStatus;
413
467
  }
414
468
  ) {
415
469
  return prisma.studentQuestionProgress.update({
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { isAIUser } from "../utils/aiUser.js";
6
6
  import { prisma } from "../lib/prisma.js";
7
- import { GenerationStatus } from "@prisma/client";
7
+ import { JobStatus } from "@prisma/client";
8
8
  import { pusher, teacherChannel } from "../lib/pusher.js";
9
9
  import type { Assignment, Class, File, Section, User } from "@prisma/client";
10
10
  import { inference, sendAIMessage } from "../utils/inference.js";
@@ -314,7 +314,7 @@ export const getBaseSystemPrompt = (
314
314
  // content: "GENERATING_CONTENT",
315
315
  // senderId: getAIUserId(),
316
316
  // conversationId: labChatId,
317
- // status: GenerationStatus.PENDING,
317
+ // status: JobStatus.PENDING,
318
318
  // },
319
319
  // });
320
320
 
@@ -599,7 +599,7 @@ export const generateAndSendLabIntroduction = async (
599
599
  if (emitOptions) {
600
600
  await prisma.message.update({
601
601
  where: { id: emitOptions.messageId },
602
- data: { status: GenerationStatus.COMPLETED },
602
+ data: { status: JobStatus.COMPLETED },
603
603
  });
604
604
  try {
605
605
  await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-completed", {
@@ -628,7 +628,7 @@ export const generateAndSendLabIntroduction = async (
628
628
  try {
629
629
  await prisma.message.update({
630
630
  where: { id: emitOptions.messageId },
631
- data: { status: GenerationStatus.FAILED },
631
+ data: { status: JobStatus.FAILED },
632
632
  });
633
633
  } catch (statusError) {
634
634
  logger.error("Failed to set message status FAILED:", {
@@ -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: {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { z } from "zod";
6
6
  import type { ResponseInput } from "openai/resources/responses/responses.js";
7
+ import { Prisma } from "@prisma/client";
7
8
  import { prisma } from "../lib/prisma.js";
8
9
  import { inference } from "../utils/inference.js";
9
10
  import { logger } from "../utils/logger.js";
@@ -109,15 +110,33 @@ If no answer is provided for a multiple-choice or true/false question, determine
109
110
  * structured inference, and inserts worksheet + questions in one DB transaction.
110
111
  */
111
112
  export async function pdfToWorksheet(gcsUrl: string, classId: string) {
112
- const sanitizedUrl = sanitizeUrlForLogs(gcsUrl);
113
+ const sourceUrl = sanitizeUrlForLogs(gcsUrl);
113
114
 
114
115
  if (!isProbablyGoogleCloudUrl(gcsUrl)) {
115
116
  logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
116
- sanitizedUrl,
117
+ sanitizedUrl: sourceUrl,
117
118
  });
118
119
  throw new Error("Only Google Cloud Storage URLs are supported");
119
120
  }
120
121
 
122
+ const existingWorksheet = await prisma.worksheet.findUnique({
123
+ where: {
124
+ classId_sourceUrl: {
125
+ classId,
126
+ sourceUrl,
127
+ },
128
+ },
129
+ });
130
+
131
+ if (existingWorksheet) {
132
+ logger.info("pdfToWorksheet: reusing existing worksheet for source PDF", {
133
+ worksheetId: existingWorksheet.id,
134
+ classId,
135
+ sourceUrl,
136
+ });
137
+ return existingWorksheet;
138
+ }
139
+
121
140
  const filename = getFilenameFromUrl(gcsUrl);
122
141
  const prompt = `Read the attached PDF and convert it into a single worksheet JSON object.\n\n${schemaInstructions}`;
123
142
 
@@ -149,29 +168,56 @@ export async function pdfToWorksheet(gcsUrl: string, classId: string) {
149
168
  return null;
150
169
  }
151
170
 
152
- return prisma.$transaction(async (tx) => {
153
- const worksheet = await tx.worksheet.create({
154
- data: { classId, name: draft.title },
155
- });
171
+ try {
172
+ return await prisma.$transaction(async (tx) => {
173
+ const worksheet = await tx.worksheet.create({
174
+ data: { classId, name: draft.title, sourceUrl },
175
+ });
156
176
 
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),
177
+ if (draft.questions.length > 0) {
178
+ await tx.worksheetQuestion.createMany({
179
+ data: draft.questions.map((question) => ({
180
+ worksheetId: worksheet.id,
181
+ type: question.type,
182
+ question: question.question,
183
+ answer: question.answer,
184
+ points: Math.round(question.points ?? 0),
185
+ options: question.options ?? [],
186
+ markScheme: (question.markScheme ?? []).map((item) => ({
187
+ ...item,
188
+ points: Math.round(item.points),
189
+ })),
190
+ order: Math.round(question.order),
169
191
  })),
170
- order: Math.round(question.order),
171
- })),
192
+ });
193
+ }
194
+
195
+ return worksheet;
196
+ });
197
+ } catch (error) {
198
+ if (
199
+ error instanceof Prisma.PrismaClientKnownRequestError &&
200
+ error.code === "P2002"
201
+ ) {
202
+ const worksheet = await prisma.worksheet.findUnique({
203
+ where: {
204
+ classId_sourceUrl: {
205
+ classId,
206
+ sourceUrl,
207
+ },
208
+ },
172
209
  });
210
+
211
+ if (worksheet) {
212
+ logger.info("pdfToWorksheet: reused worksheet created by concurrent retry", {
213
+ worksheetId: worksheet.id,
214
+ classId,
215
+ sourceUrl,
216
+ });
217
+ return worksheet;
218
+ }
173
219
  }
174
220
 
175
- return worksheet;
176
- });
221
+ throw error;
222
+ }
177
223
  }
@@ -18,6 +18,7 @@ import {
18
18
  getSubmissionById,
19
19
  getSubmissions,
20
20
  createAssignmentRecord,
21
+ duplicateAssignmentRecord,
21
22
  updateAssignmentRecord,
22
23
  deleteAssignmentRecord,
23
24
  updateSubmissionRecord,
@@ -273,6 +274,24 @@ export const assignmentRouter = createTRPCRouter({
273
274
  create: protectedTeacherProcedure
274
275
  .input(createAssignmentSchema)
275
276
  .mutation(({ ctx, input }) => createAssignmentRecord(ctx.user!.id, input)),
277
+
278
+ duplicateAssignment: protectedTeacherProcedure
279
+ .input(
280
+ z.object({
281
+ classId: z.string(),
282
+ title: z.string(),
283
+ assignmentId: z.string(),
284
+ })
285
+ )
286
+ .mutation(({ ctx, input }) =>
287
+ duplicateAssignmentRecord(
288
+ input.assignmentId,
289
+ input.classId,
290
+ input.title,
291
+ ctx.user!.id
292
+ )
293
+ ),
294
+
276
295
  update: protectedTeacherProcedure
277
296
  .input(updateAssignmentSchema)
278
297
  .mutation(({ ctx, input }) => updateAssignmentRecord(ctx.user!.id, input)),
@@ -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)),
@@ -19,6 +19,7 @@ import {
19
19
  updateWorksheetRecord,
20
20
  deleteWorksheetRecord,
21
21
  createWorksheetRecord,
22
+ duplicateWorksheetRecord,
22
23
  createWorksheetFromPdf,
23
24
  createWorksheetFromUploadedPdf,
24
25
  addQuestionToWorksheet,
@@ -108,6 +109,23 @@ export const worksheetRouter = createTRPCRouter({
108
109
  createWorksheetRecord(input.classId, input.name)
109
110
  ),
110
111
 
112
+ duplicateWorksheet: protectedTeacherProcedure
113
+ .input(
114
+ z.object({
115
+ classId: z.string(),
116
+ name: z.string(),
117
+ worksheetId: z.string(),
118
+ })
119
+ )
120
+ .mutation(({ ctx, input }) =>
121
+ duplicateWorksheetRecord(
122
+ input.worksheetId,
123
+ input.classId,
124
+ input.name,
125
+ ctx.user!.id
126
+ )
127
+ ),
128
+
111
129
  createFromPdf: protectedTeacherProcedure
112
130
  .input(z.object({ classId: z.string(), url: z.string().url() }))
113
131
  .mutation(({ input }) => createWorksheetFromPdf(input.classId, input.url)),