@studious-lms/server 1.4.9 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/index.js +15 -3
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/config/cors.d.ts.map +1 -1
  4. package/dist/lib/config/cors.js +2 -3
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts.map +1 -1
  7. package/dist/lib/fileUpload.js +4 -5
  8. package/dist/lib/fileUpload.js.map +1 -1
  9. package/dist/lib/pusher.d.ts +7 -1
  10. package/dist/lib/pusher.d.ts.map +1 -1
  11. package/dist/lib/pusher.js +9 -3
  12. package/dist/lib/pusher.js.map +1 -1
  13. package/dist/models/attendance.d.ts +16 -7
  14. package/dist/models/attendance.d.ts.map +1 -1
  15. package/dist/models/attendance.js +17 -2
  16. package/dist/models/attendance.js.map +1 -1
  17. package/dist/models/auth.d.ts +1 -0
  18. package/dist/models/auth.d.ts.map +1 -1
  19. package/dist/models/auth.js +3 -2
  20. package/dist/models/auth.js.map +1 -1
  21. package/dist/models/comment.d.ts +0 -5
  22. package/dist/models/comment.d.ts.map +1 -1
  23. package/dist/models/conversation.d.ts +17 -16
  24. package/dist/models/conversation.d.ts.map +1 -1
  25. package/dist/models/labChat.d.ts +2 -1
  26. package/dist/models/labChat.d.ts.map +1 -1
  27. package/dist/models/message.d.ts +6 -3
  28. package/dist/models/message.d.ts.map +1 -1
  29. package/dist/models/school.d.ts +0 -22
  30. package/dist/models/school.d.ts.map +1 -1
  31. package/dist/models/school.js +2 -14
  32. package/dist/models/school.js.map +1 -1
  33. package/dist/models/worksheet.d.ts +76 -19
  34. package/dist/models/worksheet.d.ts.map +1 -1
  35. package/dist/models/worksheet.js +2 -2
  36. package/dist/models/worksheet.js.map +1 -1
  37. package/dist/pipelines/aiLabChat.d.ts +20 -9
  38. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  39. package/dist/pipelines/aiLabChat.js +63 -75
  40. package/dist/pipelines/aiLabChat.js.map +1 -1
  41. package/dist/pipelines/aiNewtonChat.d.ts +13 -4
  42. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  43. package/dist/pipelines/aiNewtonChat.js +39 -27
  44. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  45. package/dist/pipelines/gradeWorksheet.d.ts +5 -3
  46. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  47. package/dist/pipelines/gradeWorksheet.js +60 -24
  48. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  49. package/dist/pipelines/pdfToWorksheet.d.ts +34 -2
  50. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  51. package/dist/pipelines/pdfToWorksheet.js +205 -32
  52. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  53. package/dist/routers/_app.d.ts +106 -48
  54. package/dist/routers/_app.d.ts.map +1 -1
  55. package/dist/routers/announcement.d.ts +0 -4
  56. package/dist/routers/announcement.d.ts.map +1 -1
  57. package/dist/routers/assignment.d.ts +0 -8
  58. package/dist/routers/assignment.d.ts.map +1 -1
  59. package/dist/routers/assignment.js +64 -4
  60. package/dist/routers/assignment.js.map +1 -1
  61. package/dist/routers/auth.d.ts +1 -0
  62. package/dist/routers/auth.d.ts.map +1 -1
  63. package/dist/routers/comment.d.ts +0 -3
  64. package/dist/routers/comment.d.ts.map +1 -1
  65. package/dist/routers/conversation.d.ts +2 -1
  66. package/dist/routers/conversation.d.ts.map +1 -1
  67. package/dist/routers/labChat.d.ts +3 -1
  68. package/dist/routers/labChat.d.ts.map +1 -1
  69. package/dist/routers/message.d.ts +3 -0
  70. package/dist/routers/message.d.ts.map +1 -1
  71. package/dist/routers/newtonChat.d.ts +1 -0
  72. package/dist/routers/newtonChat.d.ts.map +1 -1
  73. package/dist/routers/school.d.ts +6 -0
  74. package/dist/routers/school.d.ts.map +1 -1
  75. package/dist/routers/school.js +7 -2
  76. package/dist/routers/school.js.map +1 -1
  77. package/dist/routers/worksheet.d.ts +37 -7
  78. package/dist/routers/worksheet.d.ts.map +1 -1
  79. package/dist/routers/worksheet.js +16 -6
  80. package/dist/routers/worksheet.js.map +1 -1
  81. package/dist/services/announcement.js +4 -4
  82. package/dist/services/announcement.js.map +1 -1
  83. package/dist/services/assignment.d.ts.map +1 -1
  84. package/dist/services/assignment.js +16 -6
  85. package/dist/services/assignment.js.map +1 -1
  86. package/dist/services/attendance.d.ts.map +1 -1
  87. package/dist/services/attendance.js +68 -4
  88. package/dist/services/attendance.js.map +1 -1
  89. package/dist/services/auth.d.ts +1 -0
  90. package/dist/services/auth.d.ts.map +1 -1
  91. package/dist/services/comment.d.ts +0 -3
  92. package/dist/services/comment.d.ts.map +1 -1
  93. package/dist/services/conversation.d.ts +2 -1
  94. package/dist/services/conversation.d.ts.map +1 -1
  95. package/dist/services/labChat.d.ts +10 -2
  96. package/dist/services/labChat.d.ts.map +1 -1
  97. package/dist/services/labChat.js +103 -44
  98. package/dist/services/labChat.js.map +1 -1
  99. package/dist/services/message.d.ts +3 -0
  100. package/dist/services/message.d.ts.map +1 -1
  101. package/dist/services/message.js +20 -2
  102. package/dist/services/message.js.map +1 -1
  103. package/dist/services/newtonChat.d.ts +1 -0
  104. package/dist/services/newtonChat.d.ts.map +1 -1
  105. package/dist/services/newtonChat.js +40 -17
  106. package/dist/services/newtonChat.js.map +1 -1
  107. package/dist/services/school.d.ts +6 -0
  108. package/dist/services/school.d.ts.map +1 -1
  109. package/dist/services/school.js +71 -6
  110. package/dist/services/school.js.map +1 -1
  111. package/dist/services/worksheet.d.ts +48 -11
  112. package/dist/services/worksheet.d.ts.map +1 -1
  113. package/dist/services/worksheet.js +73 -29
  114. package/dist/services/worksheet.js.map +1 -1
  115. package/dist/utils/generateUsername.d.ts +8 -0
  116. package/dist/utils/generateUsername.d.ts.map +1 -0
  117. package/dist/utils/generateUsername.js +103 -0
  118. package/dist/utils/generateUsername.js.map +1 -0
  119. package/dist/utils/inference.d.ts +50 -1
  120. package/dist/utils/inference.d.ts.map +1 -1
  121. package/dist/utils/inference.js +167 -2
  122. package/dist/utils/inference.js.map +1 -1
  123. package/dist/workers/connection.d.ts +3 -0
  124. package/dist/workers/connection.d.ts.map +1 -0
  125. package/dist/workers/connection.js +33 -0
  126. package/dist/workers/connection.js.map +1 -0
  127. package/dist/workers/index.d.ts +10 -0
  128. package/dist/workers/index.d.ts.map +1 -0
  129. package/dist/workers/index.js +63 -0
  130. package/dist/workers/index.js.map +1 -0
  131. package/dist/workers/processors/aiLabChat.d.ts +7 -0
  132. package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
  133. package/dist/workers/processors/aiLabChat.js +52 -0
  134. package/dist/workers/processors/aiLabChat.js.map +1 -0
  135. package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
  136. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
  137. package/dist/workers/processors/aiNewtonChat.js +57 -0
  138. package/dist/workers/processors/aiNewtonChat.js.map +1 -0
  139. package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
  140. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
  141. package/dist/workers/processors/createRunJobWorker.js +118 -0
  142. package/dist/workers/processors/createRunJobWorker.js.map +1 -0
  143. package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
  144. package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
  145. package/dist/workers/processors/gradeWorksheet.js +19 -0
  146. package/dist/workers/processors/gradeWorksheet.js.map +1 -0
  147. package/dist/workers/processors/pdfToWorksheet.d.ts +8 -0
  148. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -0
  149. package/dist/workers/processors/pdfToWorksheet.js +20 -0
  150. package/dist/workers/processors/pdfToWorksheet.js.map +1 -0
  151. package/dist/workers/processors/studentProgressChat.d.ts +8 -0
  152. package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
  153. package/dist/workers/processors/studentProgressChat.js +27 -0
  154. package/dist/workers/processors/studentProgressChat.js.map +1 -0
  155. package/dist/workers/queues.d.ts +85 -0
  156. package/dist/workers/queues.d.ts.map +1 -0
  157. package/dist/workers/queues.js +119 -0
  158. package/dist/workers/queues.js.map +1 -0
  159. package/package.json +6 -3
  160. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +166 -0
  161. package/prisma/schema.prisma +465 -387
  162. package/src/index.ts +12 -1
  163. package/src/lib/config/cors.ts +0 -1
  164. package/src/lib/fileUpload.ts +2 -4
  165. package/src/lib/pusher.ts +7 -1
  166. package/src/models/attendance.ts +15 -0
  167. package/src/models/auth.ts +1 -0
  168. package/src/models/school.ts +0 -14
  169. package/src/models/worksheet.ts +2 -2
  170. package/src/pipelines/aiLabChat.ts +71 -79
  171. package/src/pipelines/aiNewtonChat.ts +47 -31
  172. package/src/pipelines/gradeWorksheet.ts +63 -25
  173. package/src/pipelines/pdfToWorksheet.ts +272 -34
  174. package/src/routers/assignment.ts +74 -3
  175. package/src/routers/school.ts +5 -0
  176. package/src/routers/worksheet.ts +25 -4
  177. package/src/services/announcement.ts +2 -2
  178. package/src/services/assignment.ts +18 -4
  179. package/src/services/attendance.ts +87 -2
  180. package/src/services/labChat.ts +108 -48
  181. package/src/services/message.ts +19 -0
  182. package/src/services/newtonChat.ts +46 -25
  183. package/src/services/school.ts +89 -6
  184. package/src/services/worksheet.ts +87 -28
  185. package/src/utils/generateUsername.ts +108 -0
  186. package/src/utils/inference.ts +209 -1
  187. package/src/workers/connection.ts +37 -0
  188. package/src/workers/index.ts +71 -0
  189. package/src/workers/processors/aiLabChat.ts +65 -0
  190. package/src/workers/processors/aiNewtonChat.ts +75 -0
  191. package/src/workers/processors/createRunJobWorker.ts +164 -0
  192. package/src/workers/processors/gradeWorksheet.ts +16 -0
  193. package/src/workers/processors/pdfToWorksheet.ts +17 -0
  194. package/src/workers/processors/studentProgressChat.ts +28 -0
  195. package/src/workers/queues.ts +174 -0
  196. package/tests/{globalSetup.ts → integration/globalSetup.ts} +4 -4
  197. package/tests/{helpers.ts → integration/helpers.ts} +2 -2
  198. package/tests/{pipelines → integration/pipelines}/pdfToWorksheet.test.ts +55 -43
  199. package/tests/{routers → integration/routers}/attendance.test.ts +61 -2
  200. package/tests/{routers → integration/routers}/auth.test.ts +3 -3
  201. package/tests/{routers → integration/routers}/class.test.ts +3 -17
  202. package/tests/{routers → integration/routers}/conversation.test.ts +1 -1
  203. package/tests/{routers → integration/routers}/event.test.ts +1 -1
  204. package/tests/{routers → integration/routers}/message.test.ts +1 -1
  205. package/tests/{routers → integration/routers}/newtonChat.test.ts +1 -1
  206. package/tests/{routers → integration/routers}/studentProgress.test.ts +1 -1
  207. package/tests/{user.test.ts → integration/routers/user.test.ts} +2 -2
  208. package/tests/integration/routers/worksheet.test.ts +448 -0
  209. package/tests/{setup.ts → integration/setup.ts} +4 -4
  210. package/tests/{lib → unit/lib}/cors.test.ts +1 -1
  211. package/tests/{middleware → unit/middleware}/security.test.ts +1 -1
  212. package/tests/{pipelines → unit/pipelines}/aiLabChat.test.ts +1 -1
  213. package/tests/{lib → unit/pipelines}/aiLabChatContract.test.ts +1 -1
  214. package/tests/{utils/aiLabChatPrompt.test.ts → unit/pipelines/labChatPrompt.test.ts} +2 -2
  215. package/tests/{utils → unit/services}/studentProgress.test.ts +5 -5
  216. package/tests/{utils → unit/utils}/aiUser.test.ts +1 -1
  217. package/tests/{utils → unit/utils}/generateInviteCode.test.ts +1 -1
  218. package/tests/{utils → unit/utils}/inference.test.ts +6 -6
  219. package/tests/{utils → unit/utils}/logger.test.ts +1 -1
  220. package/tests/{utils → unit/utils}/prismaErrorHandler.test.ts +1 -1
  221. package/tests/{utils → unit/utils}/prismaWrapper.test.ts +1 -1
  222. package/vitest.config.ts +11 -3
  223. package/vitest.unit.config.ts +10 -10
  224. package/tests/routers/worksheet.test.ts +0 -194
  225. package/tests/startup.test.ts +0 -5
  226. package/tests/worksheet.test.ts +0 -181
  227. /package/tests/{routers → integration/routers}/agenda.test.ts +0 -0
  228. /package/tests/{routers → integration/routers}/announcement.test.ts +0 -0
  229. /package/tests/{routers → integration/routers}/assignment.test.ts +0 -0
  230. /package/tests/{routers → integration/routers}/comment.test.ts +0 -0
  231. /package/tests/{routers → integration/routers}/folder.test.ts +0 -0
  232. /package/tests/{routers → integration/routers}/labChat.test.ts +0 -0
  233. /package/tests/{routers → integration/routers}/marketing.test.ts +0 -0
  234. /package/tests/{routers → integration/routers}/notification.test.ts +0 -0
  235. /package/tests/{routers → integration/routers}/search.test.ts +0 -0
  236. /package/tests/{routers → integration/routers}/section.test.ts +0 -0
  237. /package/tests/{server → unit/middleware}/rateLimit.test.ts +0 -0
@@ -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,11 +3,12 @@
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";
10
- import { pdfToWorksheet } from "../pipelines/pdfToWorksheet.js";
10
+ import { enqueueRunJob } from "../workers/queues.js";
11
+ import { logger } from "../utils/logger.js";
11
12
  import {
12
13
  findWorksheetByIdMinimal,
13
14
  findWorksheetByIdForClassMember,
@@ -170,21 +171,77 @@ export async function duplicateWorksheetRecord(
170
171
  }
171
172
 
172
173
  /**
173
- * Create a worksheet by converting a PDF (URL) into structured questions.
174
- * Uses the `pdfToWorksheet` pipeline to do inference + persistence.
174
+ * Kick off PDF-to-worksheet conversion asynchronously.
175
+ *
176
+ * We synchronously create a placeholder Worksheet row (status PENDING) so the
177
+ * caller has an id to navigate to immediately, then enqueue a BullMQ job that
178
+ * fills in the real title and questions. Progress is broadcast on the
179
+ * per-worksheet job Pusher channel; clients should subscribe there to drive
180
+ * loading UI.
175
181
  */
176
182
  export async function createWorksheetFromPdf(
177
183
  classId: string,
178
- url: string
184
+ url: string,
185
+ userId?: string,
186
+ options: { name?: string } = {},
179
187
  ) {
180
- const created = await pdfToWorksheet(url, classId);
181
- if (!created) {
188
+ const placeholder = await prisma.worksheet.create({
189
+ data: {
190
+ classId,
191
+ name: options.name?.trim() || "Converting PDF…",
192
+ status: JobStatus.PENDING,
193
+ },
194
+ });
195
+
196
+ try {
197
+ const { runJobId } = await enqueueRunJob(
198
+ "PDF_TO_WORKSHEET",
199
+ { gcsUrl: url, classId, worksheetId: placeholder.id },
200
+ { classId, userId: userId ?? null },
201
+ );
202
+
203
+ await prisma.worksheet
204
+ .update({
205
+ where: { id: placeholder.id },
206
+ data: { runJobId },
207
+ })
208
+ .catch((linkError) => {
209
+ logger.error(
210
+ "createWorksheetFromPdf: failed to link RunJob to Worksheet",
211
+ {
212
+ worksheetId: placeholder.id,
213
+ runJobId,
214
+ error:
215
+ linkError instanceof Error
216
+ ? linkError.message
217
+ : String(linkError),
218
+ },
219
+ );
220
+ });
221
+
222
+ return {
223
+ ...placeholder,
224
+ runJobId,
225
+ };
226
+ } catch (error) {
227
+ await prisma.worksheet
228
+ .update({
229
+ where: { id: placeholder.id },
230
+ data: { status: JobStatus.FAILED },
231
+ })
232
+ .catch(() => undefined);
233
+
234
+ logger.error("createWorksheetFromPdf: failed to enqueue conversion", {
235
+ worksheetId: placeholder.id,
236
+ classId,
237
+ error: error instanceof Error ? error.message : String(error),
238
+ });
239
+
182
240
  throw new TRPCError({
183
241
  code: "INTERNAL_SERVER_ERROR",
184
- message: "Failed to create worksheet from PDF",
242
+ message: "Failed to start worksheet conversion",
185
243
  });
186
244
  }
187
- return created;
188
245
  }
189
246
 
190
247
  /**
@@ -193,7 +250,8 @@ export async function createWorksheetFromPdf(
193
250
  export async function createWorksheetFromUploadedPdf(
194
251
  classId: string,
195
252
  fileId: string,
196
- userId: string
253
+ userId: string,
254
+ options: { name?: string } = {},
197
255
  ) {
198
256
  const file = await prisma.file.findFirst({
199
257
  where: {
@@ -205,6 +263,7 @@ export async function createWorksheetFromUploadedPdf(
205
263
  id: true,
206
264
  path: true,
207
265
  type: true,
266
+ name: true,
208
267
  },
209
268
  });
210
269
 
@@ -225,7 +284,9 @@ export async function createWorksheetFromUploadedPdf(
225
284
  }
226
285
 
227
286
  const signedUrl = await getFileUrl(file.path);
228
- return createWorksheetFromPdf(classId, signedUrl);
287
+ return createWorksheetFromPdf(classId, signedUrl, userId, {
288
+ name: options.name ?? file.name ?? undefined,
289
+ });
229
290
  }
230
291
 
231
292
  export async function addQuestionToWorksheet(
@@ -283,24 +344,22 @@ export async function reorderWorksheetQuestions(
283
344
  message: "Target question not found",
284
345
  });
285
346
  }
347
+ if (movedId === targetId) {
348
+ throw new TRPCError({
349
+ code: "BAD_REQUEST",
350
+ message: "Moved question and target question must be different",
351
+ });
352
+ }
286
353
 
287
354
  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
- ];
355
+ const targetIdxWithoutMoved = withoutMoved.findIndex((q) => q.id === targetId);
356
+ const insertIdx =
357
+ position === "before" ? targetIdxWithoutMoved : targetIdxWithoutMoved + 1;
358
+ const next = [
359
+ ...withoutMoved.slice(0, insertIdx).map((item) => ({ id: item.id })),
360
+ { id: movedId },
361
+ ...withoutMoved.slice(insertIdx).map((item) => ({ id: item.id })),
362
+ ];
304
363
 
305
364
  await prisma.$transaction(
306
365
  next.map((item, index) =>
@@ -432,7 +491,7 @@ export async function answerWorksheetQuestion(
432
491
  ...(isMarkableByAlgo && {
433
492
  points: response === correctAnswer ? marksAwardedIfCorrect : 0,
434
493
  }),
435
- status: GenerationStatus.NOT_STARTED,
494
+ status: JobStatus.NOT_STARTED,
436
495
  });
437
496
  } else {
438
497
  await createStudentQuestionProgress({
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Username generator – produces playful adjective+noun+number combos like "greenegg6767".
3
+ * Pass an `isTaken` checker to retry until a unique username is found.
4
+ */
5
+
6
+ const ADJECTIVES = [
7
+ "green",
8
+ "blue",
9
+ "red",
10
+ "purple",
11
+ "yellow",
12
+ "orange",
13
+ "pink",
14
+ "silver",
15
+ "golden",
16
+ "fuzzy",
17
+ "happy",
18
+ "sleepy",
19
+ "sneaky",
20
+ "silly",
21
+ "brave",
22
+ "clever",
23
+ "lucky",
24
+ "mighty",
25
+ "quiet",
26
+ "quick",
27
+ "loud",
28
+ "tiny",
29
+ "giant",
30
+ "spicy",
31
+ "jolly",
32
+ "wild",
33
+ "shiny",
34
+ "cosmic",
35
+ "mystic",
36
+ "swift",
37
+ "lazy",
38
+ "grumpy",
39
+ "fluffy",
40
+ "zesty",
41
+ ];
42
+
43
+ const NOUNS = [
44
+ "egg",
45
+ "cat",
46
+ "dog",
47
+ "fox",
48
+ "owl",
49
+ "bear",
50
+ "duck",
51
+ "frog",
52
+ "wolf",
53
+ "panda",
54
+ "tiger",
55
+ "otter",
56
+ "moose",
57
+ "shark",
58
+ "whale",
59
+ "raven",
60
+ "robin",
61
+ "koala",
62
+ "llama",
63
+ "sloth",
64
+ "narwhal",
65
+ "penguin",
66
+ "dragon",
67
+ "phoenix",
68
+ "muffin",
69
+ "pickle",
70
+ "noodle",
71
+ "potato",
72
+ "taco",
73
+ "pancake",
74
+ "waffle",
75
+ "cookie",
76
+ "biscuit",
77
+ "donut",
78
+ ];
79
+
80
+ function pick<T>(items: readonly T[]) {
81
+ return items[Math.floor(Math.random() * items.length)]!;
82
+ }
83
+
84
+ function randomDigits(n: number) {
85
+ let out = "";
86
+ for (let i = 0; i < n; i += 1) {
87
+ out += Math.floor(Math.random() * 10).toString();
88
+ }
89
+ return out;
90
+ }
91
+
92
+ export function generateRandomUsername() {
93
+ return `${pick(ADJECTIVES)}${pick(NOUNS)}${randomDigits(4)}`;
94
+ }
95
+
96
+ /** Generate a unique username, calling `isTaken` until one is free. */
97
+ export async function generateUniqueUsername(
98
+ isTaken: (username: string) => Promise<boolean>,
99
+ maxAttempts = 10
100
+ ) {
101
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
102
+ const candidate = generateRandomUsername();
103
+ if (!(await isTaken(candidate))) {
104
+ return candidate;
105
+ }
106
+ }
107
+ return `${generateRandomUsername()}${randomDigits(4)}`;
108
+ }
@@ -8,7 +8,7 @@ import { prisma } from '../lib/prisma.js';
8
8
  import { chatChannel, pusher } from '../lib/pusher.js';
9
9
  import { ensureAIUserExists, getAIUserId } from './aiUser.js';
10
10
  import { env } from '../lib/config/env.js';
11
- import { Prisma } from '@prisma/client';
11
+ import { JobStatus, Prisma } from '@prisma/client';
12
12
  import { ZodSchema } from 'zod';
13
13
  import { zodTextFormat } from 'openai/helpers/zod';
14
14
 
@@ -276,6 +276,214 @@ export async function sendAIMessage(
276
276
  };
277
277
  }
278
278
 
279
+ interface AIMessageSenderInfo {
280
+ displayName?: string;
281
+ profilePicture?: string | null;
282
+ }
283
+
284
+ function buildAISenderInfo(customSender?: AIMessageSenderInfo) {
285
+ return {
286
+ id: getAIUserId(),
287
+ username: 'Newton_AI',
288
+ profile: {
289
+ displayName: customSender?.displayName ?? 'Newton AI',
290
+ profilePicture: customSender?.profilePicture ?? null,
291
+ },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Create an AI message placeholder in PENDING state and broadcast it.
297
+ *
298
+ * The AI message lifecycle == job lifecycle: this row is the bubble the
299
+ * client renders for "AI is thinking..." and is later flipped by
300
+ * {@link completePendingAIMessage} / {@link failPendingAIMessage}.
301
+ */
302
+ export async function createPendingAIMessage(
303
+ conversationId: string,
304
+ options: {
305
+ customSender?: AIMessageSenderInfo;
306
+ } = {},
307
+ ): Promise<{
308
+ id: string;
309
+ conversationId: string;
310
+ createdAt: Date;
311
+ senderId: string;
312
+ status: JobStatus;
313
+ }> {
314
+ await ensureAIUserExists();
315
+
316
+ const aiMessage = await prisma.message.create({
317
+ data: {
318
+ content: '',
319
+ senderId: getAIUserId(),
320
+ conversationId,
321
+ status: JobStatus.PENDING,
322
+ },
323
+ });
324
+
325
+ logger.info('AI placeholder message created', {
326
+ messageId: aiMessage.id,
327
+ conversationId,
328
+ });
329
+
330
+ const senderInfo = buildAISenderInfo(options.customSender);
331
+
332
+ try {
333
+ await pusher.trigger(chatChannel(conversationId), 'new-message', {
334
+ id: aiMessage.id,
335
+ content: '',
336
+ senderId: getAIUserId(),
337
+ conversationId: aiMessage.conversationId,
338
+ status: JobStatus.PENDING,
339
+ createdAt: aiMessage.createdAt,
340
+ sender: senderInfo,
341
+ mentionedUserIds: [],
342
+ meta: {},
343
+ attachments: [],
344
+ });
345
+ } catch (error) {
346
+ logger.error('Failed to broadcast pending AI message:', {
347
+ error,
348
+ messageId: aiMessage.id,
349
+ });
350
+ }
351
+
352
+ return {
353
+ id: aiMessage.id,
354
+ conversationId: aiMessage.conversationId,
355
+ createdAt: aiMessage.createdAt,
356
+ senderId: aiMessage.senderId,
357
+ status: JobStatus.PENDING,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Fill a PENDING AI message with its generated content and flip it to
363
+ * COMPLETED. Broadcasts a `message-updated` event so clients that already
364
+ * rendered the placeholder can patch it in place.
365
+ */
366
+ export async function completePendingAIMessage(
367
+ messageId: string,
368
+ content: string,
369
+ options: {
370
+ attachments?: { connect: { id: string }[] };
371
+ meta?: Record<string, any>;
372
+ customSender?: AIMessageSenderInfo;
373
+ } = {},
374
+ ): Promise<{
375
+ id: string;
376
+ content: string;
377
+ conversationId: string;
378
+ createdAt: Date;
379
+ status: JobStatus;
380
+ }> {
381
+ const updated = await prisma.message.update({
382
+ where: { id: messageId },
383
+ data: {
384
+ content,
385
+ status: JobStatus.COMPLETED,
386
+ ...(options.attachments && {
387
+ attachments: {
388
+ connect: options.attachments.connect,
389
+ },
390
+ }),
391
+ ...(options.meta !== undefined && {
392
+ meta: options.meta as Prisma.InputJsonValue,
393
+ }),
394
+ },
395
+ include: {
396
+ attachments: true,
397
+ },
398
+ });
399
+
400
+ logger.info('AI message completed', {
401
+ messageId: updated.id,
402
+ conversationId: updated.conversationId,
403
+ contentLength: content.length,
404
+ });
405
+
406
+ const senderInfo = buildAISenderInfo(options.customSender);
407
+
408
+ try {
409
+ await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
410
+ id: updated.id,
411
+ content: updated.content,
412
+ senderId: updated.senderId,
413
+ conversationId: updated.conversationId,
414
+ status: updated.status,
415
+ createdAt: updated.createdAt,
416
+ sender: senderInfo,
417
+ mentionedUserIds: [],
418
+ meta: updated.meta ?? {},
419
+ attachments: updated.attachments.map((attachment) => ({
420
+ id: attachment.id,
421
+ attachmentId: attachment.id,
422
+ name: attachment.name,
423
+ type: attachment.type,
424
+ size: attachment.size,
425
+ path: attachment.path,
426
+ })),
427
+ });
428
+ } catch (error) {
429
+ logger.error('Failed to broadcast completed AI message:', {
430
+ error,
431
+ messageId,
432
+ });
433
+ }
434
+
435
+ return {
436
+ id: updated.id,
437
+ content: updated.content,
438
+ conversationId: updated.conversationId,
439
+ createdAt: updated.createdAt,
440
+ status: (updated.status ?? JobStatus.COMPLETED) as JobStatus,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Flip a PENDING AI message to FAILED and broadcast the status change.
446
+ * Does NOT throw — failure paths in the pipelines call this in catch blocks
447
+ * and we do not want broadcast/DB errors to mask the original error.
448
+ */
449
+ export async function failPendingAIMessage(
450
+ messageId: string,
451
+ options: { reason?: string } = {},
452
+ ): Promise<void> {
453
+ try {
454
+ const updated = await prisma.message.update({
455
+ where: { id: messageId },
456
+ data: { status: JobStatus.FAILED },
457
+ select: { id: true, conversationId: true, status: true },
458
+ });
459
+
460
+ logger.info('AI message marked as failed', {
461
+ messageId: updated.id,
462
+ conversationId: updated.conversationId,
463
+ reason: options.reason,
464
+ });
465
+
466
+ try {
467
+ await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
468
+ id: updated.id,
469
+ conversationId: updated.conversationId,
470
+ status: updated.status,
471
+ });
472
+ } catch (broadcastError) {
473
+ logger.error('Failed to broadcast failed AI message:', {
474
+ error: broadcastError,
475
+ messageId,
476
+ });
477
+ }
478
+ } catch (error) {
479
+ logger.error('Failed to mark AI message as failed:', {
480
+ error,
481
+ messageId,
482
+ reason: options.reason,
483
+ });
484
+ }
485
+ }
486
+
279
487
  /** Responses API user parts use `type: "input_*"`; chat completions use `text` / `image_url` / etc. */
280
488
  function isResponseInputWithInputParts(
281
489
  content: OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
@@ -0,0 +1,37 @@
1
+ import IORedis from 'ioredis';
2
+ import { env } from '../lib/config/env.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ /**
6
+ * BullMQ-specific Redis connection.
7
+ *
8
+ * Kept separate from `src/lib/redis.ts` (which is used for caching and
9
+ * rate-limiting) because BullMQ requires `maxRetriesPerRequest: null` and
10
+ * `enableReadyCheck: false` on its connection. Mixing those settings with
11
+ * general Redis usage causes unwanted behaviour on transient errors.
12
+ */
13
+ function buildBullConnection(): IORedis {
14
+ if (!env.REDIS_URL) {
15
+ throw new Error(
16
+ 'REDIS_URL is required for BullMQ. Set it in .env / .env.production / your hosting env.',
17
+ );
18
+ }
19
+
20
+ const connection = new IORedis(env.REDIS_URL, {
21
+ maxRetriesPerRequest: null,
22
+ enableReadyCheck: false,
23
+ lazyConnect: false,
24
+ });
25
+
26
+ connection.on('error', (err) => {
27
+ logger.error('BullMQ Redis connection error', { error: err.message });
28
+ });
29
+
30
+ connection.on('connect', () => {
31
+ logger.info('BullMQ Redis connected');
32
+ });
33
+
34
+ return connection;
35
+ }
36
+
37
+ export const bullConnection = buildBullConnection();