@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
@@ -121,8 +121,8 @@ export async function createAnnouncementRecord(
121
121
 
122
122
  sendToMultiple({
123
123
  receiverIds: classData.students.map((s) => s.id),
124
- title: `🔔 Announcement for ${classData.name}`,
125
- content: remarks,
124
+ title: `New announcement in ${classData.name}`,
125
+ content: `${remarks}\n\n[View announcement](/class/${classId})`,
126
126
  }).catch((error) => {
127
127
  logger.error("Failed to send announcement notifications:", error);
128
128
  });
@@ -23,9 +23,10 @@ import {
23
23
  } from "../models/assignment.js";
24
24
  import { createDirectUploadFiles, type UploadedFile } from "../lib/fileUpload.js";
25
25
  import { deleteFile } from "../lib/googleCloudStorage.js";
26
- import { gradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
27
26
  import { sendToMultiple } from "./notification.js";
27
+ import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
+ import { RunJobType } from "@prisma/client";
29
30
 
30
31
  export async function assignmentExists(id: string) {
31
32
  const assignment = await findAssignmentById(id);
@@ -312,8 +313,8 @@ export async function createAssignmentRecord(
312
313
 
313
314
  sendToMultiple({
314
315
  receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
315
- title: `New assignment for ${classData.name}`,
316
- content: `The assignment "${title}" has been created in ${classData.name}.\nDue date: ${new Date(dueDate).toLocaleDateString()}.\n[Link to assignment](/class/${classId}/assignments/${assignment.id})`,
316
+ title: `New assignment in ${classData.name}`,
317
+ content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
317
318
  }).catch((error) => {
318
319
  logger.error("Failed to send assignment notifications:", error);
319
320
  });
@@ -664,9 +665,22 @@ export async function updateSubmissionRecord(
664
665
  ) {
665
666
  const worksheetResponses = await prisma.studentWorksheetResponse.findMany({
666
667
  where: { submissionId: submission.id },
668
+ select: { id: true },
667
669
  });
668
670
  for (const wr of worksheetResponses) {
669
- gradeWorksheetPipeline(wr.id);
671
+ try {
672
+ await enqueueRunJob(
673
+ QUEUE_NAMES.GRADE_WORKSHEET as RunJobType,
674
+ { worksheetResponseId: wr.id },
675
+ { classId: submission.assignment.classId, userId },
676
+ );
677
+ } catch (error) {
678
+ logger.error("Failed to enqueue worksheet grading on submit", {
679
+ error,
680
+ worksheetResponseId: wr.id,
681
+ submissionId: submission.id,
682
+ });
683
+ }
670
684
  }
671
685
  }
672
686
  return prisma.submission.update({
@@ -14,6 +14,8 @@ import {
14
14
  createAttendanceForEvent,
15
15
  updateAttendance,
16
16
  } from "../models/attendance.js";
17
+ import { sendToMultiple } from "./notification.js";
18
+ import { logger } from "../utils/logger.js";
17
19
 
18
20
  /** Get attendance for a class (optionally filtered by event). Creates missing records. */
19
21
  export async function getAttendance(classId: string, eventId?: string) {
@@ -66,20 +68,103 @@ export async function updateAttendanceRecord(
66
68
  const presentIds = attendance.present.map((s) => s.id);
67
69
  const lateIds = attendance.late.map((s) => s.id);
68
70
  const absentIds = attendance.absent.map((s) => s.id);
71
+ const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
72
+ const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);
73
+ const classStudentIds = new Set(classData.students.map((student) => student.id));
69
74
 
70
75
  if (!existing) {
71
- return createAttendance({
76
+ const created = await createAttendance({
72
77
  classId,
73
78
  eventId: eventId ?? null,
74
79
  presentIds,
75
80
  lateIds,
76
81
  absentIds,
77
82
  });
83
+ await notifyMarkedLateOrAbsent({
84
+ classId,
85
+ className: classData.name,
86
+ eventId: eventId ?? null,
87
+ eventName: created.event?.name ?? null,
88
+ classStudentIds,
89
+ lateIds,
90
+ absentIds,
91
+ previousLateIds,
92
+ previousAbsentIds,
93
+ });
94
+ return created;
78
95
  }
79
96
 
80
- return updateAttendance(existing.id, {
97
+ const updated = await updateAttendance(existing.id, {
81
98
  presentIds,
82
99
  lateIds,
83
100
  absentIds,
84
101
  });
102
+ await notifyMarkedLateOrAbsent({
103
+ classId,
104
+ className: classData.name,
105
+ eventId: eventId ?? null,
106
+ eventName: existing.event?.name ?? null,
107
+ classStudentIds,
108
+ lateIds,
109
+ absentIds,
110
+ previousLateIds,
111
+ previousAbsentIds,
112
+ });
113
+ return updated;
114
+ }
115
+
116
+ /** Notify only students whose late or absent status is newly added. */
117
+ async function notifyMarkedLateOrAbsent(input: {
118
+ classId: string;
119
+ className: string;
120
+ eventId: string | null;
121
+ eventName: string | null;
122
+ classStudentIds: Set<string>;
123
+ lateIds: string[];
124
+ absentIds: string[];
125
+ previousLateIds: Set<string>;
126
+ previousAbsentIds: Set<string>;
127
+ }) {
128
+ const lateReceiverIds = [...new Set(input.lateIds)].filter(
129
+ (id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id)
130
+ );
131
+ const absentReceiverIds = [...new Set(input.absentIds)].filter(
132
+ (id) => input.classStudentIds.has(id) && !input.previousAbsentIds.has(id)
133
+ );
134
+ const link = input.eventId
135
+ ? `/class/${input.classId}/attendance/${input.eventId}`
136
+ : `/class/${input.classId}/attendance`;
137
+ const attendanceLabel = input.eventName
138
+ ? `"${input.eventName}" in ${input.className}`
139
+ : input.className;
140
+
141
+ await Promise.all([
142
+ sendAttendanceNotification({
143
+ receiverIds: lateReceiverIds,
144
+ title: `Marked late in ${input.className}`,
145
+ content: `You were marked late for ${attendanceLabel}.\n[View attendance](${link})`,
146
+ }),
147
+ sendAttendanceNotification({
148
+ receiverIds: absentReceiverIds,
149
+ title: `Marked absent in ${input.className}`,
150
+ content: `You were marked absent for ${attendanceLabel}.\n[View attendance](${link})`,
151
+ }),
152
+ ]);
153
+ }
154
+
155
+ /** Send attendance notifications without failing the attendance update itself. */
156
+ async function sendAttendanceNotification(input: {
157
+ receiverIds: string[];
158
+ title: string;
159
+ content: string;
160
+ }) {
161
+ if (input.receiverIds.length === 0) {
162
+ return;
163
+ }
164
+
165
+ try {
166
+ await sendToMultiple(input);
167
+ } catch (error) {
168
+ logger.error("Failed to send attendance notifications:", { error });
169
+ }
85
170
  }
@@ -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, RunJobType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { chatChannel, teacherChannel, pusher } from "../lib/pusher.js";
9
9
  import {
@@ -17,9 +17,10 @@ import {
17
17
  findLabChatForDelete,
18
18
  findMentionedMemberships,
19
19
  } from "../models/labChat.js";
20
- import { generateAndSendLabIntroduction, generateAndSendLabResponse } from "../pipelines/aiLabChat.js";
21
- import { isAIUser } from "../utils/aiUser.js";
20
+ import { getAIUserId, isAIUser } from "../utils/aiUser.js";
21
+ import { createPendingAIMessage } from "../utils/inference.js";
22
22
  import { logger } from "../utils/logger.js";
23
+ import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
23
24
  import {
24
25
  claimMessageAttachments,
25
26
  ensureMessageHasContentOrAttachments,
@@ -125,12 +126,23 @@ export async function createLabChat(
125
126
  return labChat;
126
127
  });
127
128
 
128
- generateAndSendLabIntroduction(
129
- result.id,
130
- result.conversationId,
131
- context,
132
- classWithTeachers.subject || "Lab"
133
- );
129
+ // Create the AI introduction placeholder *outside* the transaction (the
130
+ // helper triggers Pusher and we don't want a tx rollback to leave a
131
+ // dangling broadcast). The pipeline will fill this row in.
132
+ try {
133
+ const aiPlaceholder = await createPendingAIMessage(result.conversationId);
134
+ await enqueueRunJob(
135
+ QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
136
+ { conversationId: result.conversationId, aiMessageId: aiPlaceholder.id },
137
+ { classId, userId },
138
+ );
139
+ } catch (error) {
140
+ logger.error("Failed to enqueue lab chat introduction", {
141
+ error,
142
+ labChatId: result.id,
143
+ conversationId: result.conversationId,
144
+ });
145
+ }
134
146
 
135
147
  try {
136
148
  await pusher.trigger(teacherChannel(classId), "lab-chat-created", {
@@ -172,11 +184,13 @@ export async function getLabChat(userId: string, labChatId: string) {
172
184
  });
173
185
  }
174
186
 
187
+ // The AI placeholder row IS the pending generation marker now — its
188
+ // status flips PENDING → COMPLETED / FAILED as the pipeline runs.
175
189
  const pendingMessage = await prisma.message.findFirst({
176
190
  where: {
177
191
  conversationId: labChat.conversationId,
178
- status: GenerationStatus.PENDING,
179
- senderId: { not: "AI_ASSISTANT" },
192
+ status: JobStatus.PENDING,
193
+ senderId: getAIUserId(),
180
194
  },
181
195
  orderBy: { createdAt: "desc" },
182
196
  select: { id: true },
@@ -252,7 +266,6 @@ export async function postToLabChat(
252
266
  content: normalizedContent,
253
267
  senderId: userId,
254
268
  conversationId: labChat.conversationId,
255
- ...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
256
269
  },
257
270
  });
258
271
 
@@ -309,6 +322,7 @@ export async function postToLabChat(
309
322
  content: result.content,
310
323
  senderId: result.senderId,
311
324
  conversationId: result.conversationId,
325
+ status: result.status,
312
326
  createdAt: result.createdAt,
313
327
  sender: result.sender,
314
328
  attachments: result.attachments ?? [],
@@ -321,26 +335,49 @@ export async function postToLabChat(
321
335
  }
322
336
 
323
337
  if (!isAIUser(userId)) {
338
+ // Create the AI placeholder post-tx so a transaction rollback can never
339
+ // leave a dangling PENDING bubble. The placeholder broadcast IS the
340
+ // "AI is thinking..." indicator inline in the chat; the teacher-channel
341
+ // event below drives the lab list "AI busy in this lab" pill.
342
+ let aiPlaceholderId: string | null = null;
343
+ try {
344
+ const aiPlaceholder = await createPendingAIMessage(labChat.conversationId);
345
+ aiPlaceholderId = aiPlaceholder.id;
346
+ } catch (error) {
347
+ logger.error("Failed to create AI placeholder for lab response", {
348
+ error,
349
+ labChatId,
350
+ teacherMessageId: result.id,
351
+ conversationId: labChat.conversationId,
352
+ });
353
+ }
354
+
324
355
  try {
325
356
  await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
326
357
  labChatId,
327
- messageId: result.id,
358
+ messageId: aiPlaceholderId ?? result.id,
328
359
  conversationId: labChat.conversationId,
329
360
  });
330
361
  } catch (error) {
331
362
  console.error("Failed to broadcast lab response pending:", error);
332
363
  }
333
- generateAndSendLabResponse(labChatId, normalizedContent, {
334
- classId: labChat.classId,
335
- messageId: result.id,
336
- }).catch((error) =>
337
- logger.error("Failed to generate lab response", {
338
- error,
339
- labChatId,
340
- messageId: result.id,
341
- conversationId: labChat.conversationId,
342
- })
343
- );
364
+
365
+ if (aiPlaceholderId) {
366
+ try {
367
+ await enqueueRunJob(
368
+ QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
369
+ { conversationId: labChat.conversationId, aiMessageId: aiPlaceholderId },
370
+ { classId: labChat.classId, userId },
371
+ );
372
+ } catch (error) {
373
+ logger.error("Failed to enqueue lab response", {
374
+ error,
375
+ labChatId,
376
+ aiMessageId: aiPlaceholderId,
377
+ conversationId: labChat.conversationId,
378
+ });
379
+ }
380
+ }
344
381
  }
345
382
 
346
383
  return {
@@ -348,6 +385,7 @@ export async function postToLabChat(
348
385
  content: result.content,
349
386
  senderId: result.senderId,
350
387
  conversationId: result.conversationId,
388
+ status: result.status,
351
389
  createdAt: result.createdAt,
352
390
  sender: result.sender,
353
391
  attachments: result.attachments ?? [],
@@ -406,7 +444,13 @@ export async function deleteLabChat(userId: string, labChatId: string) {
406
444
  return { success: true };
407
445
  }
408
446
 
409
- /** Rerun the last AI response for a lab chat. Deletes the last AI message and regenerates. */
447
+ /**
448
+ * Rerun the last AI response in a lab chat.
449
+ *
450
+ * The AI message bubble is reused (not deleted): we flip it back to
451
+ * PENDING and clear its content so the existing message id stays stable
452
+ * for the client. The pipeline then refills it.
453
+ */
410
454
  export async function rerunLastResponse(userId: string, labChatId: string) {
411
455
  const labChat = await findLabChatForPost(labChatId, userId);
412
456
  if (!labChat) {
@@ -446,46 +490,62 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
446
490
  });
447
491
  }
448
492
 
449
- await prisma.$transaction([
450
- prisma.message.delete({
451
- where: { id: lastMessage.id },
452
- }),
453
- prisma.message.update({
454
- where: { id: teacherMessage.id },
455
- data: { status: GenerationStatus.PENDING },
456
- }),
457
- ]);
493
+ const reusedAiMessage = await prisma.message.update({
494
+ where: { id: lastMessage.id },
495
+ data: {
496
+ content: "",
497
+ status: JobStatus.PENDING,
498
+ meta: {},
499
+ },
500
+ select: { id: true, conversationId: true, status: true, content: true, senderId: true, createdAt: true },
501
+ });
458
502
 
503
+ // Tell the chat UI the existing AI bubble is regenerating — content
504
+ // cleared, status flipped back to PENDING, same id.
459
505
  try {
460
- await pusher.trigger(chatChannel(labChat.conversationId), "message-deleted", {
461
- messageId: lastMessage.id,
462
- conversationId: labChat.conversationId,
463
- senderId: lastMessage.senderId,
506
+ await pusher.trigger(chatChannel(labChat.conversationId), "message-updated", {
507
+ id: reusedAiMessage.id,
508
+ content: reusedAiMessage.content,
509
+ senderId: reusedAiMessage.senderId,
510
+ conversationId: reusedAiMessage.conversationId,
511
+ status: reusedAiMessage.status,
512
+ createdAt: reusedAiMessage.createdAt,
513
+ sender: {
514
+ id: reusedAiMessage.senderId,
515
+ username: 'Newton_AI',
516
+ profile: { displayName: 'Newton AI', profilePicture: null },
517
+ },
518
+ mentionedUserIds: [],
519
+ meta: {},
520
+ attachments: [],
464
521
  });
465
522
  } catch (error) {
466
- console.error("Failed to broadcast message deletion:", error);
523
+ console.error("Failed to broadcast AI message rerun reset:", error);
467
524
  }
468
525
  try {
469
526
  await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
470
527
  labChatId,
471
- messageId: teacherMessage.id,
528
+ messageId: reusedAiMessage.id,
472
529
  conversationId: labChat.conversationId,
473
530
  });
474
531
  } catch (error) {
475
532
  console.error("Failed to broadcast lab response pending:", error);
476
533
  }
477
534
 
478
- generateAndSendLabResponse(labChatId, teacherMessage.content, {
479
- classId: labChat.classId,
480
- messageId: teacherMessage.id,
481
- }).catch((error) =>
482
- logger.error("Failed to generate lab response", {
535
+ try {
536
+ await enqueueRunJob(
537
+ QUEUE_NAMES.AI_LAB_CHAT as RunJobType,
538
+ { conversationId: labChat.conversationId, aiMessageId: reusedAiMessage.id },
539
+ { classId: labChat.classId, userId },
540
+ );
541
+ } catch (error) {
542
+ logger.error("Failed to enqueue lab response rerun", {
483
543
  error,
484
544
  labChatId,
485
- messageId: teacherMessage.id,
545
+ aiMessageId: reusedAiMessage.id,
486
546
  conversationId: labChat.conversationId,
487
- })
488
- );
547
+ });
548
+ }
489
549
 
490
550
  return { success: true };
491
551
  }
@@ -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,
@@ -179,6 +192,7 @@ export async function listMessages(
179
192
  conversationId: message.conversationId,
180
193
  createdAt: message.createdAt,
181
194
  sender: message.sender,
195
+ status: message.status,
182
196
  attachments: message.attachments.map((a) => ({
183
197
  id: a.id,
184
198
  name: a.name,
@@ -303,6 +317,7 @@ export async function sendMessage(
303
317
  content: result.content,
304
318
  senderId: result.senderId,
305
319
  conversationId: result.conversationId,
320
+ status: result.status,
306
321
  createdAt: result.createdAt,
307
322
  sender: result.sender,
308
323
  attachments: result.attachments ?? [],
@@ -323,6 +338,7 @@ export async function sendMessage(
323
338
  content: result.content,
324
339
  senderId: result.senderId,
325
340
  conversationId: result.conversationId,
341
+ status: result.status,
326
342
  createdAt: result.createdAt,
327
343
  sender: result.sender,
328
344
  attachments: result.attachments ?? [],
@@ -427,6 +443,7 @@ export async function updateMessage(
427
443
  content: updatedMessage.content,
428
444
  senderId: updatedMessage.senderId,
429
445
  conversationId: updatedMessage.conversationId,
446
+ status: updatedMessage.status,
430
447
  createdAt: updatedMessage.createdAt,
431
448
  sender: updatedMessage.sender,
432
449
  attachments: updatedMessage.attachments ?? [],
@@ -447,6 +464,7 @@ export async function updateMessage(
447
464
  content: updatedMessage.content,
448
465
  senderId: updatedMessage.senderId,
449
466
  conversationId: updatedMessage.conversationId,
467
+ status: updatedMessage.status,
450
468
  createdAt: updatedMessage.createdAt,
451
469
  sender: updatedMessage.sender,
452
470
  mentionedUserIds,
@@ -620,6 +638,7 @@ export async function markSuggestionCreated(
620
638
  content: updated.content,
621
639
  senderId: updated.senderId,
622
640
  conversationId: updated.conversationId,
641
+ status: updated.status,
623
642
  createdAt: updated.createdAt,
624
643
  sender: updated.sender,
625
644
  attachments: updated.attachments ?? [],
@@ -6,7 +6,8 @@ import { TRPCError } from "@trpc/server";
6
6
  import { logger } from "../utils/logger.js";
7
7
  import { chatChannel, pusher } from "../lib/pusher.js";
8
8
  import { isAIUser } from "../utils/aiUser.js";
9
- import { generateAndSendNewtonIntroduction, generateAndSendNewtonResponse } from "../pipelines/aiNewtonChat.js";
9
+ import { createPendingAIMessage } from "../utils/inference.js";
10
+ import { enqueueRunJob, QUEUE_NAMES } from "../workers/queues.js";
10
11
  import {
11
12
  claimMessageAttachments,
12
13
  ensureMessageHasContentOrAttachments,
@@ -18,6 +19,7 @@ import {
18
19
  findConversationMembersByUserIds,
19
20
  } from "../models/newtonChat.js";
20
21
  import { prisma } from "../lib/prisma.js";
22
+ import { RunJobType } from "@prisma/client";
21
23
 
22
24
  /** Get or create Newton tutor conversation for a submission. Student-only. */
23
25
  export async function getTutorConversation(
@@ -44,7 +46,7 @@ export async function getTutorConversation(
44
46
  });
45
47
  }
46
48
 
47
- const result = await prisma.$transaction(async (tx) => {
49
+ const { chat, isNew } = await prisma.$transaction(async (tx) => {
48
50
  const existingNewtonChat = await tx.newtonChat.findFirst({
49
51
  where: { submissionId: submission.id },
50
52
  include: {
@@ -60,7 +62,7 @@ export async function getTutorConversation(
60
62
  });
61
63
 
62
64
  if (existingNewtonChat && existingNewtonChat.conversation.members.length > 0) {
63
- return existingNewtonChat;
65
+ return { chat: existingNewtonChat, isNew: false };
64
66
  }
65
67
 
66
68
  if (existingNewtonChat) {
@@ -71,7 +73,7 @@ export async function getTutorConversation(
71
73
  role: "MEMBER",
72
74
  },
73
75
  });
74
- return existingNewtonChat;
76
+ return { chat: existingNewtonChat, isNew: false };
75
77
  }
76
78
 
77
79
  const conversation = await tx.conversation.create({
@@ -98,23 +100,31 @@ export async function getTutorConversation(
98
100
  },
99
101
  });
100
102
 
101
- generateAndSendNewtonIntroduction(
102
- newtonChat.id,
103
- newtonChat.conversationId,
104
- submission.id
105
- ).catch((error) => {
106
- logger.error("Failed to generate AI introduction:", {
103
+ return { chat: newtonChat, isNew: true };
104
+ });
105
+
106
+ if (isNew) {
107
+ // Create the AI introduction placeholder and enqueue OUTSIDE the
108
+ // transaction — a tx rollback would otherwise orphan a job pointing at
109
+ // a non-existent NewtonChat / Message row.
110
+ try {
111
+ const aiPlaceholder = await createPendingAIMessage(chat.conversationId);
112
+ await enqueueRunJob(
113
+ QUEUE_NAMES.AI_NEWTON_CHAT as RunJobType,
114
+ { conversationId: chat.conversationId, aiMessageId: aiPlaceholder.id },
115
+ { userId },
116
+ );
117
+ } catch (error) {
118
+ logger.error("Failed to enqueue Newton introduction", {
107
119
  error,
108
- newtonChatId: newtonChat.id,
120
+ newtonChatId: chat.id,
109
121
  });
110
- });
111
-
112
- return newtonChat;
113
- });
122
+ }
123
+ }
114
124
 
115
125
  return {
116
- conversationId: result.conversationId,
117
- newtonChatId: result.id,
126
+ conversationId: chat.conversationId,
127
+ newtonChatId: chat.id,
118
128
  };
119
129
  }
120
130
 
@@ -218,6 +228,7 @@ export async function postToNewtonChat(
218
228
  content: result.content,
219
229
  senderId: result.senderId,
220
230
  conversationId: result.conversationId,
231
+ status: result.status,
221
232
  createdAt: result.createdAt,
222
233
  sender: result.sender,
223
234
  attachments: result.attachments ?? [],
@@ -230,14 +241,23 @@ export async function postToNewtonChat(
230
241
  }
231
242
 
232
243
  if (!isAIUser(userId)) {
233
- generateAndSendNewtonResponse(
234
- newtonChatId,
235
- normalizedContent,
236
- newtonChat.conversationId,
237
- newtonChat.submission
238
- ).catch((error) => {
239
- logger.error("Failed to generate AI response:", { error });
240
- });
244
+ // Create the AI placeholder post-tx (so a rollback can't broadcast a
245
+ // ghost bubble); the pipeline fills it in.
246
+ try {
247
+ const aiPlaceholder = await createPendingAIMessage(newtonChat.conversationId);
248
+ await enqueueRunJob(
249
+ QUEUE_NAMES.AI_NEWTON_CHAT as RunJobType,
250
+ { conversationId: newtonChat.conversationId, aiMessageId: aiPlaceholder.id },
251
+ { userId },
252
+ );
253
+ } catch (error) {
254
+ logger.error("Failed to enqueue Newton response", {
255
+ error,
256
+ newtonChatId,
257
+ studentMessageId: result.id,
258
+ conversationId: newtonChat.conversationId,
259
+ });
260
+ }
241
261
  }
242
262
 
243
263
  return {
@@ -245,6 +265,7 @@ export async function postToNewtonChat(
245
265
  content: result.content,
246
266
  senderId: result.senderId,
247
267
  conversationId: result.conversationId,
268
+ status: result.status,
248
269
  createdAt: result.createdAt,
249
270
  sender: result.sender,
250
271
  attachments: result.attachments ?? [],