@studious-lms/server 1.4.9 → 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 (160) hide show
  1. package/dist/lib/config/cors.d.ts.map +1 -1
  2. package/dist/lib/config/cors.js +2 -4
  3. package/dist/lib/config/cors.js.map +1 -1
  4. package/dist/lib/fileUpload.d.ts.map +1 -1
  5. package/dist/lib/fileUpload.js +4 -5
  6. package/dist/lib/fileUpload.js.map +1 -1
  7. package/dist/models/auth.d.ts +1 -0
  8. package/dist/models/auth.d.ts.map +1 -1
  9. package/dist/models/auth.js +3 -2
  10. package/dist/models/auth.js.map +1 -1
  11. package/dist/models/comment.d.ts +0 -5
  12. package/dist/models/comment.d.ts.map +1 -1
  13. package/dist/models/conversation.d.ts +2 -1
  14. package/dist/models/conversation.d.ts.map +1 -1
  15. package/dist/models/labChat.d.ts +2 -1
  16. package/dist/models/labChat.d.ts.map +1 -1
  17. package/dist/models/message.d.ts +6 -3
  18. package/dist/models/message.d.ts.map +1 -1
  19. package/dist/models/school.d.ts +0 -22
  20. package/dist/models/school.d.ts.map +1 -1
  21. package/dist/models/school.js +2 -14
  22. package/dist/models/school.js.map +1 -1
  23. package/dist/models/worksheet.d.ts +76 -19
  24. package/dist/models/worksheet.d.ts.map +1 -1
  25. package/dist/models/worksheet.js +2 -2
  26. package/dist/models/worksheet.js.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +6 -6
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/gradeWorksheet.d.ts +5 -3
  30. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  31. package/dist/pipelines/gradeWorksheet.js +60 -24
  32. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  33. package/dist/pipelines/pdfToWorksheet.d.ts +3 -0
  34. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  35. package/dist/pipelines/pdfToWorksheet.js +65 -24
  36. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  37. package/dist/routers/_app.d.ts +92 -48
  38. package/dist/routers/_app.d.ts.map +1 -1
  39. package/dist/routers/announcement.d.ts +0 -4
  40. package/dist/routers/announcement.d.ts.map +1 -1
  41. package/dist/routers/assignment.d.ts +0 -8
  42. package/dist/routers/assignment.d.ts.map +1 -1
  43. package/dist/routers/auth.d.ts +1 -0
  44. package/dist/routers/auth.d.ts.map +1 -1
  45. package/dist/routers/comment.d.ts +0 -3
  46. package/dist/routers/comment.d.ts.map +1 -1
  47. package/dist/routers/conversation.d.ts +2 -1
  48. package/dist/routers/conversation.d.ts.map +1 -1
  49. package/dist/routers/labChat.d.ts +2 -1
  50. package/dist/routers/labChat.d.ts.map +1 -1
  51. package/dist/routers/school.d.ts +6 -0
  52. package/dist/routers/school.d.ts.map +1 -1
  53. package/dist/routers/school.js +7 -2
  54. package/dist/routers/school.js.map +1 -1
  55. package/dist/routers/worksheet.d.ts +35 -7
  56. package/dist/routers/worksheet.d.ts.map +1 -1
  57. package/dist/services/auth.d.ts +1 -0
  58. package/dist/services/auth.d.ts.map +1 -1
  59. package/dist/services/comment.d.ts +0 -3
  60. package/dist/services/comment.d.ts.map +1 -1
  61. package/dist/services/conversation.d.ts +2 -1
  62. package/dist/services/conversation.d.ts.map +1 -1
  63. package/dist/services/labChat.d.ts +2 -1
  64. package/dist/services/labChat.d.ts.map +1 -1
  65. package/dist/services/labChat.js +6 -6
  66. package/dist/services/labChat.js.map +1 -1
  67. package/dist/services/message.d.ts.map +1 -1
  68. package/dist/services/message.js +14 -2
  69. package/dist/services/message.js.map +1 -1
  70. package/dist/services/school.d.ts +6 -0
  71. package/dist/services/school.d.ts.map +1 -1
  72. package/dist/services/school.js +71 -6
  73. package/dist/services/school.js.map +1 -1
  74. package/dist/services/worksheet.d.ts +35 -7
  75. package/dist/services/worksheet.d.ts.map +1 -1
  76. package/dist/services/worksheet.js +17 -19
  77. package/dist/services/worksheet.js.map +1 -1
  78. package/dist/utils/generateUsername.d.ts +8 -0
  79. package/dist/utils/generateUsername.d.ts.map +1 -0
  80. package/dist/utils/generateUsername.js +103 -0
  81. package/dist/utils/generateUsername.js.map +1 -0
  82. package/dist/workers/connection.d.ts +3 -0
  83. package/dist/workers/connection.d.ts.map +1 -0
  84. package/dist/workers/connection.js +33 -0
  85. package/dist/workers/connection.js.map +1 -0
  86. package/dist/workers/index.d.ts +10 -0
  87. package/dist/workers/index.d.ts.map +1 -0
  88. package/dist/workers/index.js +53 -0
  89. package/dist/workers/index.js.map +1 -0
  90. package/dist/workers/processors/pdfToWorksheet.d.ts +9 -0
  91. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
  92. package/dist/workers/processors/pdfToWorksheet.js +100 -0
  93. package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
  94. package/dist/workers/queues.d.ts +69 -0
  95. package/dist/workers/queues.d.ts.map +1 -0
  96. package/dist/workers/queues.js +119 -0
  97. package/dist/workers/queues.js.map +1 -0
  98. package/package.json +4 -1
  99. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
  100. package/prisma/schema.prisma +465 -387
  101. package/src/lib/config/cors.ts +0 -2
  102. package/src/lib/fileUpload.ts +2 -4
  103. package/src/models/auth.ts +1 -0
  104. package/src/models/school.ts +0 -14
  105. package/src/models/worksheet.ts +2 -2
  106. package/src/pipelines/aiLabChat.ts +4 -4
  107. package/src/pipelines/gradeWorksheet.ts +63 -25
  108. package/src/pipelines/pdfToWorksheet.ts +68 -22
  109. package/src/routers/school.ts +5 -0
  110. package/src/services/labChat.ts +4 -4
  111. package/src/services/message.ts +13 -0
  112. package/src/services/school.ts +89 -6
  113. package/src/services/worksheet.ts +16 -18
  114. package/src/utils/generateUsername.ts +108 -0
  115. package/src/workers/connection.ts +37 -0
  116. package/src/workers/index.ts +61 -0
  117. package/src/workers/processors/pdfToWorksheet.ts +119 -0
  118. package/src/workers/queues.ts +159 -0
  119. package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
  120. package/tests/{helpers.ts → integration/helpers.ts} +2 -2
  121. package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
  122. package/tests/{routers → integration/routers}/attendance.test.ts +1 -1
  123. package/tests/{routers → integration/routers}/auth.test.ts +3 -3
  124. package/tests/{routers → integration/routers}/class.test.ts +3 -17
  125. package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
  126. package/tests/{routers → integration/routers}/event.test.ts +1 -1
  127. package/tests/{routers → integration/routers}/message.test.ts +1 -1
  128. package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
  129. package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
  130. package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
  131. package/tests/integration/routers/worksheet.test.ts +448 -0
  132. package/tests/{setup.ts → integration/setup.ts} +4 -4
  133. package/tests/{lib → unit/lib}/cors.test.ts +1 -1
  134. package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
  135. package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
  136. package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
  137. package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
  138. package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
  139. package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
  140. package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
  141. package/tests/{utils → unit/utils}/inference.test.ts +6 -6
  142. package/tests/{utils → unit/utils}/logger.test.ts +1 -1
  143. package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
  144. package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
  145. package/vitest.config.ts +11 -3
  146. package/vitest.unit.config.ts +10 -10
  147. package/tests/routers/worksheet.test.ts +0 -194
  148. package/tests/startup.test.ts +0 -5
  149. package/tests/worksheet.test.ts +0 -181
  150. /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
  151. /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
  152. /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
  153. /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
  154. /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
  155. /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
  156. /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
  157. /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
  158. /package/tests/{routers → integration/routers}/search.test.ts +0 -0
  159. /package/tests/{routers → integration/routers}/section.test.ts +0 -0
  160. /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
@@ -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;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { Prisma } from "@prisma/client";
5
5
  import { prisma } from "../lib/prisma.js";
6
- import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
6
+ import type { WorksheetQuestionType, JobStatus } from "@prisma/client";
7
7
  import {
8
8
  classMemberOrSchoolAdminWhere,
9
9
  classTeacherOrSchoolAdminWhere,
@@ -463,7 +463,7 @@ export function updateStudentQuestionProgress(
463
463
  response?: string;
464
464
  isCorrect?: boolean;
465
465
  points?: number;
466
- status?: GenerationStatus;
466
+ status?: JobStatus;
467
467
  }
468
468
  ) {
469
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
  }
@@ -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)),
@@ -3,7 +3,7 @@
3
3
  * trigger AI responses, and broadcast via Pusher.
4
4
  */
5
5
  import { TRPCError } from "@trpc/server";
6
- import { GenerationStatus } from "@prisma/client";
6
+ import { JobStatus } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { chatChannel, teacherChannel, pusher } from "../lib/pusher.js";
9
9
  import {
@@ -175,7 +175,7 @@ export async function getLabChat(userId: string, labChatId: string) {
175
175
  const pendingMessage = await prisma.message.findFirst({
176
176
  where: {
177
177
  conversationId: labChat.conversationId,
178
- status: GenerationStatus.PENDING,
178
+ status: JobStatus.PENDING,
179
179
  senderId: { not: "AI_ASSISTANT" },
180
180
  },
181
181
  orderBy: { createdAt: "desc" },
@@ -252,7 +252,7 @@ export async function postToLabChat(
252
252
  content: normalizedContent,
253
253
  senderId: userId,
254
254
  conversationId: labChat.conversationId,
255
- ...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
255
+ ...(isAIUser(userId) ? {} : { status: JobStatus.PENDING }),
256
256
  },
257
257
  });
258
258
 
@@ -452,7 +452,7 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
452
452
  }),
453
453
  prisma.message.update({
454
454
  where: { id: teacherMessage.id },
455
- data: { status: GenerationStatus.PENDING },
455
+ data: { status: JobStatus.PENDING },
456
456
  }),
457
457
  ]);
458
458
 
@@ -110,6 +110,19 @@ export async function confirmMessageUpload(
110
110
  });
111
111
  }
112
112
 
113
+ if (input.uploadSuccess) {
114
+ await prisma.$transaction(async (tx) => {
115
+ await tx.file.update({
116
+ where: { id: input.fileId },
117
+ data: { uploadStatus: "FAILED", uploadError: input.errorMessage },
118
+ });
119
+ });
120
+ throw new TRPCError({
121
+ code: "INTERNAL_SERVER_ERROR",
122
+ message: "Failed to confirm upload",
123
+ });
124
+ }
125
+
113
126
  const file = await prisma.file.findFirst({
114
127
  where: {
115
128
  id: input.fileId,
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * School service - school admin workflow, memberships, and invites.
3
3
  */
4
- import { SchoolMembershipRole } from "@prisma/client";
4
+ import { SchoolMembershipRole, SessionType } from "@prisma/client";
5
5
  import { TRPCError } from "@trpc/server";
6
+ import { hash } from "bcryptjs";
6
7
  import { v4 as uuidv4 } from "uuid";
7
8
  import { env } from "../lib/config/env.js";
8
9
  import { prisma } from "../lib/prisma.js";
9
10
  import { sendMail } from "../utils/email.js";
11
+ import { generateUniqueUsername } from "../utils/generateUsername.js";
10
12
  import { logger } from "../utils/logger.js";
13
+ import {
14
+ createSession,
15
+ createUser,
16
+ findUserByEmail,
17
+ findUserById,
18
+ findUserByUsername,
19
+ } from "../models/auth.js";
20
+ import { upsertUserProfile } from "../models/user.js";
11
21
  import {
12
22
  acceptSchoolInvite,
13
23
  countActiveAdmins,
@@ -16,7 +26,6 @@ import {
16
26
  findActiveSchoolAdminMembership,
17
27
  findPendingInviteByToken,
18
28
  findSchoolMembership,
19
- findUserByIdForSchool,
20
29
  getSchoolById,
21
30
  getSchoolOverview,
22
31
  listSchoolClasses,
@@ -225,9 +234,61 @@ export async function listInvites(userId: string, schoolId: string) {
225
234
 
226
235
  export async function createInvite(
227
236
  userId: string,
228
- input: { schoolId: string; email: string; role: SchoolMembershipRole }
237
+ input: {
238
+ schoolId: string;
239
+ email: string;
240
+ role: SchoolMembershipRole;
241
+ username?: string;
242
+ displayName?: string;
243
+ }
229
244
  ) {
230
245
  await requireSchoolAdmin(input.schoolId, userId);
246
+
247
+ const existingUser = await findUserByEmail(input.email);
248
+
249
+ let createdUser: { id: string; username: string } | null = null;
250
+ let passwordResetToken: string | null = null;
251
+
252
+ if (!existingUser) {
253
+ let username: string;
254
+ if (input.username) {
255
+ const taken = await findUserByUsername(input.username);
256
+ if (taken) {
257
+ throw new TRPCError({
258
+ code: "CONFLICT",
259
+ message: "Username already taken",
260
+ });
261
+ }
262
+ username = input.username;
263
+ } else {
264
+ username = await generateUniqueUsername(async (candidate) => {
265
+ const existing = await findUserByUsername(candidate);
266
+ return Boolean(existing);
267
+ });
268
+ }
269
+
270
+ const placeholderPassword = await hash(uuidv4(), 10);
271
+ const user = await createUser({
272
+ username,
273
+ email: input.email,
274
+ password: placeholderPassword,
275
+ verified: true,
276
+ });
277
+ createdUser = { id: user.id, username: user.username };
278
+
279
+ if (input.displayName) {
280
+ await upsertUserProfile(user.id, { displayName: input.displayName });
281
+ }
282
+
283
+ const resetSession = await createSession({
284
+ id: uuidv4(),
285
+ type: SessionType.PASSWORD_RESET,
286
+ userId: user.id,
287
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
288
+ });
289
+ passwordResetToken = resetSession.id;
290
+ }
291
+
231
292
  const token = uuidv4();
232
293
  const invite = await createSchoolInvite({
233
294
  schoolId: input.schoolId,
@@ -238,12 +299,34 @@ export async function createInvite(
238
299
  expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
239
300
  });
240
301
 
302
+ if (createdUser) {
303
+ await acceptSchoolInvite(invite.id, createdUser.id);
304
+ }
305
+
306
+ const inviteUrl = `${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`;
307
+ const passwordResetUrl = passwordResetToken
308
+ ? `${env.NEXT_PUBLIC_APP_URL}/reset-password/${passwordResetToken}`
309
+ : null;
310
+
311
+ const emailLines = [
312
+ `You've been invited to ${invite.school.name} on Studious.`,
313
+ "",
314
+ ];
315
+ if (createdUser && passwordResetUrl) {
316
+ emailLines.push(
317
+ `We created an account for you with the username "${createdUser.username}" and added you to ${invite.school.name}.`,
318
+ `Set your password to get started: ${passwordResetUrl}`
319
+ );
320
+ } else {
321
+ emailLines.push(`Click the link to confirm: ${inviteUrl}`);
322
+ }
323
+
241
324
  try {
242
325
  await sendMail({
243
326
  from: "hello@studious.sh",
244
327
  to: input.email,
245
328
  subject: `Join ${invite.school.name} on Studious`,
246
- text: `Click the link to join ${invite.school.name}: ${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`,
329
+ text: emailLines.join("\n"),
247
330
  });
248
331
  } catch (err) {
249
332
  logger.error("Failed to send school invite email", {
@@ -253,7 +336,7 @@ export async function createInvite(
253
336
  });
254
337
  }
255
338
 
256
- return { invite };
339
+ return { invite, createdUser };
257
340
  }
258
341
 
259
342
  export async function revokeInvite(
@@ -292,7 +375,7 @@ export async function acceptInvite(userId: string, token: string) {
292
375
  throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
293
376
  }
294
377
 
295
- const user = await findUserByIdForSchool(userId);
378
+ const user = await findUserById(userId);
296
379
  if (!user) {
297
380
  throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
298
381
  }
@@ -3,7 +3,7 @@
3
3
  * Integrates with gradeWorksheet pipeline for auto-grading.
4
4
  */
5
5
  import { TRPCError } from "@trpc/server";
6
- import { GenerationStatus, type WorksheetQuestionType } from "@prisma/client";
6
+ import { JobStatus, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { getFileUrl } from "../lib/fileUpload.js";
9
9
  import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
@@ -283,24 +283,22 @@ export async function reorderWorksheetQuestions(
283
283
  message: "Target question not found",
284
284
  });
285
285
  }
286
+ if (movedId === targetId) {
287
+ throw new TRPCError({
288
+ code: "BAD_REQUEST",
289
+ message: "Moved question and target question must be different",
290
+ });
291
+ }
286
292
 
287
293
  const withoutMoved = questions.filter((q) => q.id !== movedId);
288
- const next =
289
- position === "before"
290
- ? [
291
- ...withoutMoved.slice(0, targetIdx).map((item) => ({ id: item.id })),
292
- { id: movedId },
293
- ...withoutMoved.slice(targetIdx).map((item) => ({ id: item.id })),
294
- ]
295
- : [
296
- ...withoutMoved
297
- .slice(0, targetIdx + 1)
298
- .map((item) => ({ id: item.id })),
299
- { id: movedId },
300
- ...withoutMoved
301
- .slice(targetIdx + 1)
302
- .map((item) => ({ id: item.id })),
303
- ];
294
+ const targetIdxWithoutMoved = withoutMoved.findIndex((q) => q.id === targetId);
295
+ const insertIdx =
296
+ position === "before" ? targetIdxWithoutMoved : targetIdxWithoutMoved + 1;
297
+ const next = [
298
+ ...withoutMoved.slice(0, insertIdx).map((item) => ({ id: item.id })),
299
+ { id: movedId },
300
+ ...withoutMoved.slice(insertIdx).map((item) => ({ id: item.id })),
301
+ ];
304
302
 
305
303
  await prisma.$transaction(
306
304
  next.map((item, index) =>
@@ -432,7 +430,7 @@ export async function answerWorksheetQuestion(
432
430
  ...(isMarkableByAlgo && {
433
431
  points: response === correctAnswer ? marksAwardedIfCorrect : 0,
434
432
  }),
435
- status: GenerationStatus.NOT_STARTED,
433
+ status: JobStatus.NOT_STARTED,
436
434
  });
437
435
  } else {
438
436
  await createStudentQuestionProgress({