@studious-lms/server 1.5.0 → 1.7.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 (141) 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 +3 -2
  5. package/dist/lib/config/cors.js.map +1 -1
  6. package/dist/lib/pusher.d.ts +7 -1
  7. package/dist/lib/pusher.d.ts.map +1 -1
  8. package/dist/lib/pusher.js +9 -3
  9. package/dist/lib/pusher.js.map +1 -1
  10. package/dist/models/attendance.d.ts +16 -7
  11. package/dist/models/attendance.d.ts.map +1 -1
  12. package/dist/models/attendance.js +17 -2
  13. package/dist/models/attendance.js.map +1 -1
  14. package/dist/models/conversation.d.ts +33 -17
  15. package/dist/models/conversation.d.ts.map +1 -1
  16. package/dist/models/conversation.js +50 -2
  17. package/dist/models/conversation.js.map +1 -1
  18. package/dist/pipelines/aiLabChat.d.ts +20 -9
  19. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  20. package/dist/pipelines/aiLabChat.js +62 -74
  21. package/dist/pipelines/aiLabChat.js.map +1 -1
  22. package/dist/pipelines/aiNewtonChat.d.ts +13 -4
  23. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  24. package/dist/pipelines/aiNewtonChat.js +39 -27
  25. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  26. package/dist/pipelines/pdfToWorksheet.d.ts +31 -2
  27. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  28. package/dist/pipelines/pdfToWorksheet.js +160 -28
  29. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  30. package/dist/routers/_app.d.ts +48 -4
  31. package/dist/routers/_app.d.ts.map +1 -1
  32. package/dist/routers/assignment.d.ts.map +1 -1
  33. package/dist/routers/assignment.js +64 -4
  34. package/dist/routers/assignment.js.map +1 -1
  35. package/dist/routers/conversation.d.ts +15 -0
  36. package/dist/routers/conversation.d.ts.map +1 -1
  37. package/dist/routers/conversation.js +6 -3
  38. package/dist/routers/conversation.js.map +1 -1
  39. package/dist/routers/labChat.d.ts +1 -0
  40. package/dist/routers/labChat.d.ts.map +1 -1
  41. package/dist/routers/message.d.ts +3 -0
  42. package/dist/routers/message.d.ts.map +1 -1
  43. package/dist/routers/newtonChat.d.ts +1 -0
  44. package/dist/routers/newtonChat.d.ts.map +1 -1
  45. package/dist/routers/worksheet.d.ts +4 -2
  46. package/dist/routers/worksheet.d.ts.map +1 -1
  47. package/dist/routers/worksheet.js +16 -6
  48. package/dist/routers/worksheet.js.map +1 -1
  49. package/dist/services/announcement.js +4 -4
  50. package/dist/services/announcement.js.map +1 -1
  51. package/dist/services/assignment.d.ts.map +1 -1
  52. package/dist/services/assignment.js +16 -6
  53. package/dist/services/assignment.js.map +1 -1
  54. package/dist/services/attendance.d.ts.map +1 -1
  55. package/dist/services/attendance.js +68 -4
  56. package/dist/services/attendance.js.map +1 -1
  57. package/dist/services/conversation.d.ts +16 -0
  58. package/dist/services/conversation.d.ts.map +1 -1
  59. package/dist/services/conversation.js +22 -3
  60. package/dist/services/conversation.js.map +1 -1
  61. package/dist/services/labChat.d.ts +8 -1
  62. package/dist/services/labChat.d.ts.map +1 -1
  63. package/dist/services/labChat.js +101 -42
  64. package/dist/services/labChat.js.map +1 -1
  65. package/dist/services/message.d.ts +3 -0
  66. package/dist/services/message.d.ts.map +1 -1
  67. package/dist/services/message.js +8 -2
  68. package/dist/services/message.js.map +1 -1
  69. package/dist/services/newtonChat.d.ts +1 -0
  70. package/dist/services/newtonChat.d.ts.map +1 -1
  71. package/dist/services/newtonChat.js +40 -17
  72. package/dist/services/newtonChat.js.map +1 -1
  73. package/dist/services/worksheet.d.ts +15 -6
  74. package/dist/services/worksheet.d.ts.map +1 -1
  75. package/dist/services/worksheet.js +58 -12
  76. package/dist/services/worksheet.js.map +1 -1
  77. package/dist/utils/inference.d.ts +50 -1
  78. package/dist/utils/inference.d.ts.map +1 -1
  79. package/dist/utils/inference.js +167 -2
  80. package/dist/utils/inference.js.map +1 -1
  81. package/dist/workers/index.js +13 -3
  82. package/dist/workers/index.js.map +1 -1
  83. package/dist/workers/processors/aiLabChat.d.ts +7 -0
  84. package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
  85. package/dist/workers/processors/aiLabChat.js +52 -0
  86. package/dist/workers/processors/aiLabChat.js.map +1 -0
  87. package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
  88. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
  89. package/dist/workers/processors/aiNewtonChat.js +57 -0
  90. package/dist/workers/processors/aiNewtonChat.js.map +1 -0
  91. package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
  92. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
  93. package/dist/workers/processors/createRunJobWorker.js +118 -0
  94. package/dist/workers/processors/createRunJobWorker.js.map +1 -0
  95. package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
  96. package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
  97. package/dist/workers/processors/gradeWorksheet.js +19 -0
  98. package/dist/workers/processors/gradeWorksheet.js.map +1 -0
  99. package/dist/workers/processors/pdfToWorksheet.d.ts +5 -6
  100. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  101. package/dist/workers/processors/pdfToWorksheet.js +15 -95
  102. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  103. package/dist/workers/processors/studentProgressChat.d.ts +8 -0
  104. package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
  105. package/dist/workers/processors/studentProgressChat.js +27 -0
  106. package/dist/workers/processors/studentProgressChat.js.map +1 -0
  107. package/dist/workers/queues.d.ts +20 -4
  108. package/dist/workers/queues.d.ts.map +1 -1
  109. package/dist/workers/queues.js +3 -3
  110. package/dist/workers/queues.js.map +1 -1
  111. package/package.json +3 -3
  112. package/src/index.ts +12 -1
  113. package/src/lib/config/cors.ts +1 -0
  114. package/src/lib/pusher.ts +7 -1
  115. package/src/models/attendance.ts +15 -0
  116. package/src/models/conversation.ts +54 -0
  117. package/src/pipelines/aiLabChat.ts +70 -78
  118. package/src/pipelines/aiNewtonChat.ts +47 -31
  119. package/src/pipelines/pdfToWorksheet.ts +224 -32
  120. package/src/routers/assignment.ts +74 -3
  121. package/src/routers/conversation.ts +7 -0
  122. package/src/routers/worksheet.ts +25 -4
  123. package/src/services/announcement.ts +2 -2
  124. package/src/services/assignment.ts +17 -4
  125. package/src/services/attendance.ts +87 -2
  126. package/src/services/conversation.ts +30 -0
  127. package/src/services/labChat.ts +106 -46
  128. package/src/services/message.ts +6 -0
  129. package/src/services/newtonChat.ts +45 -25
  130. package/src/services/worksheet.ts +71 -10
  131. package/src/utils/inference.ts +209 -1
  132. package/src/workers/index.ts +11 -1
  133. package/src/workers/processors/aiLabChat.ts +65 -0
  134. package/src/workers/processors/aiNewtonChat.ts +75 -0
  135. package/src/workers/processors/createRunJobWorker.ts +164 -0
  136. package/src/workers/processors/gradeWorksheet.ts +16 -0
  137. package/src/workers/processors/pdfToWorksheet.ts +12 -114
  138. package/src/workers/processors/studentProgressChat.ts +28 -0
  139. package/src/workers/queues.ts +20 -5
  140. package/tests/integration/routers/attendance.test.ts +60 -1
  141. package/tests/integration/routers/conversation.test.ts +121 -2
@@ -8,6 +8,7 @@ import {
8
8
  countUnreadMessages,
9
9
  countUnreadMentions,
10
10
  findUserByUsername,
11
+ searchInstitutionConversationMembers,
11
12
  findDmConversationsByUserId,
12
13
  findUsersByIdsOrUsernames,
13
14
  createConversation,
@@ -17,6 +18,11 @@ import {
17
18
  deleteConversationMember,
18
19
  } from "../models/conversation.js";
19
20
 
21
+ /** Maximum number of member suggestions returned for conversation search. */
22
+ const MAX_MEMBER_SEARCH_RESULTS = 10;
23
+ /** Minimum non-whitespace characters required before running member search. */
24
+ const MIN_MEMBER_SEARCH_QUERY_LENGTH = 2;
25
+
20
26
  /** List user's conversations with unread and mention counts. */
21
27
  export async function listConversations(userId: string) {
22
28
  const conversations = await findConversationsByUserId(userId);
@@ -89,6 +95,30 @@ export async function hideConversation(userId: string, conversationId: string) {
89
95
  return { success: true };
90
96
  }
91
97
 
98
+ /**
99
+ * Search active school peers that can be selected for a new conversation.
100
+ *
101
+ * Blank or one-character input returns an empty list instead of searching all
102
+ * institution members, which keeps typeahead callers from issuing broad result
103
+ * queries.
104
+ */
105
+ export async function searchConversationMembers(userId: string, query: string) {
106
+ const trimmedQuery = query.trim();
107
+
108
+ if (
109
+ !trimmedQuery ||
110
+ trimmedQuery.length < MIN_MEMBER_SEARCH_QUERY_LENGTH
111
+ ) {
112
+ return [];
113
+ }
114
+
115
+ return searchInstitutionConversationMembers(
116
+ userId,
117
+ trimmedQuery,
118
+ MAX_MEMBER_SEARCH_RESULTS
119
+ );
120
+ }
121
+
92
122
  /** Create a DM or group conversation with the given members. */
93
123
  export async function createConversationRecord(
94
124
  userId: string,
@@ -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 } 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
+ "AI_LAB_CHAT",
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
192
  status: JobStatus.PENDING,
179
- senderId: { not: "AI_ASSISTANT" },
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: JobStatus.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
+ "AI_LAB_CHAT",
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: JobStatus.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
+ "AI_LAB_CHAT",
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
  }
@@ -192,6 +192,7 @@ export async function listMessages(
192
192
  conversationId: message.conversationId,
193
193
  createdAt: message.createdAt,
194
194
  sender: message.sender,
195
+ status: message.status,
195
196
  attachments: message.attachments.map((a) => ({
196
197
  id: a.id,
197
198
  name: a.name,
@@ -316,6 +317,7 @@ export async function sendMessage(
316
317
  content: result.content,
317
318
  senderId: result.senderId,
318
319
  conversationId: result.conversationId,
320
+ status: result.status,
319
321
  createdAt: result.createdAt,
320
322
  sender: result.sender,
321
323
  attachments: result.attachments ?? [],
@@ -336,6 +338,7 @@ export async function sendMessage(
336
338
  content: result.content,
337
339
  senderId: result.senderId,
338
340
  conversationId: result.conversationId,
341
+ status: result.status,
339
342
  createdAt: result.createdAt,
340
343
  sender: result.sender,
341
344
  attachments: result.attachments ?? [],
@@ -440,6 +443,7 @@ export async function updateMessage(
440
443
  content: updatedMessage.content,
441
444
  senderId: updatedMessage.senderId,
442
445
  conversationId: updatedMessage.conversationId,
446
+ status: updatedMessage.status,
443
447
  createdAt: updatedMessage.createdAt,
444
448
  sender: updatedMessage.sender,
445
449
  attachments: updatedMessage.attachments ?? [],
@@ -460,6 +464,7 @@ export async function updateMessage(
460
464
  content: updatedMessage.content,
461
465
  senderId: updatedMessage.senderId,
462
466
  conversationId: updatedMessage.conversationId,
467
+ status: updatedMessage.status,
463
468
  createdAt: updatedMessage.createdAt,
464
469
  sender: updatedMessage.sender,
465
470
  mentionedUserIds,
@@ -633,6 +638,7 @@ export async function markSuggestionCreated(
633
638
  content: updated.content,
634
639
  senderId: updated.senderId,
635
640
  conversationId: updated.conversationId,
641
+ status: updated.status,
636
642
  createdAt: updated.createdAt,
637
643
  sender: updated.sender,
638
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 } from "../workers/queues.js";
10
11
  import {
11
12
  claimMessageAttachments,
12
13
  ensureMessageHasContentOrAttachments,
@@ -44,7 +45,7 @@ export async function getTutorConversation(
44
45
  });
45
46
  }
46
47
 
47
- const result = await prisma.$transaction(async (tx) => {
48
+ const { chat, isNew } = await prisma.$transaction(async (tx) => {
48
49
  const existingNewtonChat = await tx.newtonChat.findFirst({
49
50
  where: { submissionId: submission.id },
50
51
  include: {
@@ -60,7 +61,7 @@ export async function getTutorConversation(
60
61
  });
61
62
 
62
63
  if (existingNewtonChat && existingNewtonChat.conversation.members.length > 0) {
63
- return existingNewtonChat;
64
+ return { chat: existingNewtonChat, isNew: false };
64
65
  }
65
66
 
66
67
  if (existingNewtonChat) {
@@ -71,7 +72,7 @@ export async function getTutorConversation(
71
72
  role: "MEMBER",
72
73
  },
73
74
  });
74
- return existingNewtonChat;
75
+ return { chat: existingNewtonChat, isNew: false };
75
76
  }
76
77
 
77
78
  const conversation = await tx.conversation.create({
@@ -98,23 +99,31 @@ export async function getTutorConversation(
98
99
  },
99
100
  });
100
101
 
101
- generateAndSendNewtonIntroduction(
102
- newtonChat.id,
103
- newtonChat.conversationId,
104
- submission.id
105
- ).catch((error) => {
106
- logger.error("Failed to generate AI introduction:", {
102
+ return { chat: newtonChat, isNew: true };
103
+ });
104
+
105
+ if (isNew) {
106
+ // Create the AI introduction placeholder and enqueue OUTSIDE the
107
+ // transaction — a tx rollback would otherwise orphan a job pointing at
108
+ // a non-existent NewtonChat / Message row.
109
+ try {
110
+ const aiPlaceholder = await createPendingAIMessage(chat.conversationId);
111
+ await enqueueRunJob(
112
+ "AI_NEWTON_CHAT",
113
+ { conversationId: chat.conversationId, aiMessageId: aiPlaceholder.id },
114
+ { userId },
115
+ );
116
+ } catch (error) {
117
+ logger.error("Failed to enqueue Newton introduction", {
107
118
  error,
108
- newtonChatId: newtonChat.id,
119
+ newtonChatId: chat.id,
109
120
  });
110
- });
111
-
112
- return newtonChat;
113
- });
121
+ }
122
+ }
114
123
 
115
124
  return {
116
- conversationId: result.conversationId,
117
- newtonChatId: result.id,
125
+ conversationId: chat.conversationId,
126
+ newtonChatId: chat.id,
118
127
  };
119
128
  }
120
129
 
@@ -218,6 +227,7 @@ export async function postToNewtonChat(
218
227
  content: result.content,
219
228
  senderId: result.senderId,
220
229
  conversationId: result.conversationId,
230
+ status: result.status,
221
231
  createdAt: result.createdAt,
222
232
  sender: result.sender,
223
233
  attachments: result.attachments ?? [],
@@ -230,14 +240,23 @@ export async function postToNewtonChat(
230
240
  }
231
241
 
232
242
  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
- });
243
+ // Create the AI placeholder post-tx (so a rollback can't broadcast a
244
+ // ghost bubble); the pipeline fills it in.
245
+ try {
246
+ const aiPlaceholder = await createPendingAIMessage(newtonChat.conversationId);
247
+ await enqueueRunJob(
248
+ "AI_NEWTON_CHAT",
249
+ { conversationId: newtonChat.conversationId, aiMessageId: aiPlaceholder.id },
250
+ { userId },
251
+ );
252
+ } catch (error) {
253
+ logger.error("Failed to enqueue Newton response", {
254
+ error,
255
+ newtonChatId,
256
+ studentMessageId: result.id,
257
+ conversationId: newtonChat.conversationId,
258
+ });
259
+ }
241
260
  }
242
261
 
243
262
  return {
@@ -245,6 +264,7 @@ export async function postToNewtonChat(
245
264
  content: result.content,
246
265
  senderId: result.senderId,
247
266
  conversationId: result.conversationId,
267
+ status: result.status,
248
268
  createdAt: result.createdAt,
249
269
  sender: result.sender,
250
270
  attachments: result.attachments ?? [],
@@ -7,7 +7,8 @@ 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(