@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
@@ -6,6 +6,7 @@ import { prisma } from "../lib/prisma.js";
6
6
  import {
7
7
  findAssignmentById,
8
8
  findAssignmentWithDetails,
9
+ findAssignmentWithClassForTeacher,
9
10
  findAssignmentForDelete,
10
11
  findAssignmentsDueToday,
11
12
  findSubmissionByAssignmentAndStudent,
@@ -15,10 +16,10 @@ import {
15
16
  findSubmissionsByAssignmentForTeacher,
16
17
  createSubmission,
17
18
  findSectionsByClassId,
18
- findAssignmentWithClassForTeacher,
19
19
  findAvailableEventsForAssignment,
20
20
  attachAssignmentToEvent,
21
21
  detachAssignmentFromEvent,
22
+ duplicateAssignment,
22
23
  } from "../models/assignment.js";
23
24
  import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
24
25
  import { deleteFile } from "../lib/googleCloudStorage.js";
@@ -320,6 +321,51 @@ export async function createAssignmentRecord(
320
321
  return assignment;
321
322
  }
322
323
 
324
+ /**
325
+ * Duplicate an assignment into another class. Caller must teach both the source assignment’s
326
+ * class and the target class.
327
+ */
328
+ export async function duplicateAssignmentRecord(
329
+ assignmentId: string,
330
+ classId: string,
331
+ title: string,
332
+ userId: string
333
+ ) {
334
+ const source = await findAssignmentWithClassForTeacher(assignmentId, userId);
335
+ if (!source) {
336
+ throw new TRPCError({
337
+ code: "NOT_FOUND",
338
+ message:
339
+ "Assignment not found or you are not a teacher of the class it belongs to",
340
+ });
341
+ }
342
+
343
+ const targetOk = await prisma.class.findFirst({
344
+ where: {
345
+ id: classId,
346
+ teachers: { some: { id: userId } },
347
+ },
348
+ select: { id: true },
349
+ });
350
+
351
+ if (!targetOk) {
352
+ throw new TRPCError({
353
+ code: "FORBIDDEN",
354
+ message: "You are not a teacher of the target class",
355
+ });
356
+ }
357
+
358
+ const created = await duplicateAssignment(assignmentId, classId, title, userId);
359
+ if (!created) {
360
+ throw new TRPCError({
361
+ code: "NOT_FOUND",
362
+ message: "Assignment not found or could not be duplicated",
363
+ });
364
+ }
365
+
366
+ return created;
367
+ }
368
+
323
369
  export async function updateAssignmentRecord(
324
370
  userId: string,
325
371
  input: {
@@ -25,9 +25,35 @@ import {
25
25
  findFullExportableClass,
26
26
  createClassByImport,
27
27
  } from "../models/class.js";
28
- import { listUserSchoolMemberships } from "../models/school.js";
29
28
  import { Class, Folder } from "@prisma/client";
30
29
 
30
+ async function requireActiveSchoolMembers(schoolId: string, userIds: string[]) {
31
+ const uniqueUserIds = [...new Set(userIds)];
32
+ if (!uniqueUserIds.length) {
33
+ return;
34
+ }
35
+
36
+ const memberships = await prisma.schoolMembership.findMany({
37
+ where: {
38
+ schoolId,
39
+ status: "ACTIVE",
40
+ userId: { in: uniqueUserIds },
41
+ },
42
+ select: { userId: true },
43
+ });
44
+ const activeMemberIds = new Set(
45
+ memberships.map((membership) => membership.userId)
46
+ );
47
+ const hasNonMember = uniqueUserIds.some((id) => !activeMemberIds.has(id));
48
+
49
+ if (hasNonMember) {
50
+ throw new TRPCError({
51
+ code: "FORBIDDEN",
52
+ message: "All class members must belong to this school",
53
+ });
54
+ }
55
+ }
56
+
31
57
  /** Get all classes where user is teacher or student, with assignments and members. */
32
58
  export async function getAllClasses(userId: string) {
33
59
  const [teacherClasses, studentClasses] = await Promise.all([
@@ -128,8 +154,10 @@ export async function createClass(
128
154
  const { students, teachers, name, section, subject, color, schoolId } = input;
129
155
 
130
156
  if (schoolId) {
131
- const memberships = await listUserSchoolMemberships(userId);
132
- const membership = memberships.find((item) => item.schoolId === schoolId);
157
+ const membership = await prisma.schoolMembership.findFirst({
158
+ where: { userId, schoolId, status: "ACTIVE" },
159
+ select: { role: true },
160
+ });
133
161
 
134
162
  if (!membership) {
135
163
  throw new TRPCError({
@@ -146,19 +174,11 @@ export async function createClass(
146
174
  }
147
175
  }
148
176
 
149
- if (teachers && teachers.length > 0 && students && students.length > 0) {
150
- return prisma.class.create({
151
- data: {
152
- name,
153
- section,
154
- subject,
155
- color,
156
- schoolId,
157
- teachers: { connect: teachers.map((id) => ({ id })) },
158
- students: { connect: students.map((id) => ({ id })) },
159
- },
160
- include: { teachers: true, students: true },
161
- });
177
+ const teacherIds = teachers?.length ? teachers : [userId];
178
+ const studentIds = students?.length ? students : [];
179
+
180
+ if (schoolId) {
181
+ await requireActiveSchoolMembers(schoolId, [...teacherIds, ...studentIds]);
162
182
  }
163
183
 
164
184
  return prisma.class.create({
@@ -168,8 +188,12 @@ export async function createClass(
168
188
  subject,
169
189
  color,
170
190
  schoolId,
171
- teachers: { connect: { id: userId } },
191
+ teachers: { connect: teacherIds.map((id) => ({ id })) },
192
+ ...(studentIds.length
193
+ ? { students: { connect: studentIds.map((id) => ({ id })) } }
194
+ : {}),
172
195
  },
196
+ include: { teachers: true, students: true },
173
197
  });
174
198
  }
175
199
 
@@ -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";
@@ -14,6 +14,7 @@ import {
14
14
  findWorksheetByIdForTeacher,
15
15
  findWorksheetsByClassId,
16
16
  createWorksheet,
17
+ duplicateWorksheet,
17
18
  updateWorksheet,
18
19
  deleteWorksheet,
19
20
  findQuestionsByWorksheetId,
@@ -123,6 +124,51 @@ export async function createWorksheetRecord(classId: string, name: string) {
123
124
  return createWorksheet({ classId, name });
124
125
  }
125
126
 
127
+ /**
128
+ * Duplicate a worksheet into another class. Caller must teach both the source worksheet's class
129
+ * and the target class.
130
+ */
131
+ export async function duplicateWorksheetRecord(
132
+ worksheetId: string,
133
+ classId: string,
134
+ name: string,
135
+ userId: string
136
+ ) {
137
+ const source = await findWorksheetByIdForTeacher(worksheetId, userId);
138
+ if (!source) {
139
+ throw new TRPCError({
140
+ code: "NOT_FOUND",
141
+ message:
142
+ "Worksheet not found or you are not a teacher of the class it belongs to",
143
+ });
144
+ }
145
+
146
+ const targetOk = await prisma.class.findFirst({
147
+ where: {
148
+ id: classId,
149
+ teachers: { some: { id: userId } },
150
+ },
151
+ select: { id: true },
152
+ });
153
+
154
+ if (!targetOk) {
155
+ throw new TRPCError({
156
+ code: "FORBIDDEN",
157
+ message: "You are not a teacher of the target class",
158
+ });
159
+ }
160
+
161
+ const created = await duplicateWorksheet(worksheetId, classId, name);
162
+ if (!created) {
163
+ throw new TRPCError({
164
+ code: "NOT_FOUND",
165
+ message: "Worksheet not found",
166
+ });
167
+ }
168
+
169
+ return created;
170
+ }
171
+
126
172
  /**
127
173
  * Create a worksheet by converting a PDF (URL) into structured questions.
128
174
  * Uses the `pdfToWorksheet` pipeline to do inference + persistence.
@@ -237,24 +283,22 @@ export async function reorderWorksheetQuestions(
237
283
  message: "Target question not found",
238
284
  });
239
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
+ }
240
292
 
241
293
  const withoutMoved = questions.filter((q) => q.id !== movedId);
242
- const next =
243
- position === "before"
244
- ? [
245
- ...withoutMoved.slice(0, targetIdx).map((item) => ({ id: item.id })),
246
- { id: movedId },
247
- ...withoutMoved.slice(targetIdx).map((item) => ({ id: item.id })),
248
- ]
249
- : [
250
- ...withoutMoved
251
- .slice(0, targetIdx + 1)
252
- .map((item) => ({ id: item.id })),
253
- { id: movedId },
254
- ...withoutMoved
255
- .slice(targetIdx + 1)
256
- .map((item) => ({ id: item.id })),
257
- ];
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
+ ];
258
302
 
259
303
  await prisma.$transaction(
260
304
  next.map((item, index) =>
@@ -386,7 +430,7 @@ export async function answerWorksheetQuestion(
386
430
  ...(isMarkableByAlgo && {
387
431
  points: response === correctAnswer ? marksAwardedIfCorrect : 0,
388
432
  }),
389
- status: GenerationStatus.NOT_STARTED,
433
+ status: JobStatus.NOT_STARTED,
390
434
  });
391
435
  } else {
392
436
  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
+ }
@@ -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();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Worker process entrypoint.
3
+ *
4
+ * Run separately from the HTTP server (e.g. `node dist/workers/index.js` on
5
+ * Render/Railway/Fly). Importing this file boots every BullMQ Worker
6
+ * declared under `src/workers/processors/*`. Do NOT import this from the
7
+ * HTTP server — that would spin up workers in your serverless functions.
8
+ */
9
+ import '../instrument.js';
10
+
11
+ import { Worker } from 'bullmq';
12
+ import { prisma } from '../lib/prisma.js';
13
+ import { logger } from '../utils/logger.js';
14
+ import { bullConnection } from './connection.js';
15
+ import { pdfToWorksheetWorker } from './processors/pdfToWorksheet.js';
16
+
17
+ const workers: Worker<any>[] = [pdfToWorksheetWorker];
18
+
19
+ logger.info('Workers ready', {
20
+ pid: process.pid,
21
+ queues: workers.map((w) => w.name),
22
+ });
23
+
24
+ let shuttingDown = false;
25
+
26
+ async function shutdown(signal: string): Promise<void> {
27
+ if (shuttingDown) return;
28
+ shuttingDown = true;
29
+
30
+ logger.info(`Received ${signal}, draining workers...`);
31
+
32
+ // `worker.close()` waits for in-flight jobs to finish (up to the worker's
33
+ // settings) before resolving. Run them in parallel.
34
+ await Promise.allSettled(workers.map((w) => w.close()));
35
+
36
+ await Promise.allSettled([
37
+ bullConnection.quit(),
38
+ prisma.$disconnect(),
39
+ ]);
40
+
41
+ logger.info('Workers shut down cleanly');
42
+ process.exit(0);
43
+ }
44
+
45
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
46
+ process.on('SIGINT', () => void shutdown('SIGINT'));
47
+
48
+ process.on('uncaughtException', (error) => {
49
+ logger.error('Uncaught exception in worker process', {
50
+ error: error.message,
51
+ stack: error.stack,
52
+ });
53
+ process.exit(1);
54
+ });
55
+
56
+ process.on('unhandledRejection', (reason) => {
57
+ logger.error('Unhandled rejection in worker process', {
58
+ reason: reason instanceof Error ? reason.message : String(reason),
59
+ });
60
+ process.exit(1);
61
+ });