@studious-lms/server 1.25.0 → 1.27.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 (175) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/models/cloudStorage.d.ts +11 -0
  5. package/dist/models/cloudStorage.d.ts.map +1 -1
  6. package/dist/models/cloudStorage.js +18 -2
  7. package/dist/models/cloudStorage.js.map +1 -1
  8. package/dist/models/conversation.d.ts +1 -0
  9. package/dist/models/conversation.d.ts.map +1 -1
  10. package/dist/models/labChat.d.ts +40 -0
  11. package/dist/models/labChat.d.ts.map +1 -1
  12. package/dist/models/labChat.js +32 -2
  13. package/dist/models/labChat.js.map +1 -1
  14. package/dist/models/marketplace.d.ts +35 -0
  15. package/dist/models/marketplace.d.ts.map +1 -1
  16. package/dist/models/marketplace.js +9 -2
  17. package/dist/models/marketplace.js.map +1 -1
  18. package/dist/models/message.d.ts +3 -0
  19. package/dist/models/message.d.ts.map +1 -1
  20. package/dist/models/module.d.ts +228 -0
  21. package/dist/models/module.d.ts.map +1 -0
  22. package/dist/models/module.js +69 -0
  23. package/dist/models/module.js.map +1 -0
  24. package/dist/pipelines/aiLabChat.d.ts +2 -0
  25. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  26. package/dist/pipelines/aiLabChat.js +60 -16
  27. package/dist/pipelines/aiLabChat.js.map +1 -1
  28. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  29. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  30. package/dist/pipelines/aiLabChatContract.js +30 -2
  31. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  32. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.js +30 -5
  34. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +4 -2
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  39. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  40. package/dist/pipelines/labChatPrompt.js +13 -14
  41. package/dist/pipelines/labChatPrompt.js.map +1 -1
  42. package/dist/routers/_app.d.ts +1185 -215
  43. package/dist/routers/_app.d.ts.map +1 -1
  44. package/dist/routers/_app.js +4 -2
  45. package/dist/routers/_app.js.map +1 -1
  46. package/dist/routers/admin.d.ts +0 -1
  47. package/dist/routers/admin.d.ts.map +1 -1
  48. package/dist/routers/conversation.d.ts +1 -0
  49. package/dist/routers/conversation.d.ts.map +1 -1
  50. package/dist/routers/labChat.d.ts +10 -0
  51. package/dist/routers/labChat.d.ts.map +1 -1
  52. package/dist/routers/labChat.js +6 -3
  53. package/dist/routers/labChat.js.map +1 -1
  54. package/dist/routers/marketplace.d.ts +41 -0
  55. package/dist/routers/marketplace.d.ts.map +1 -1
  56. package/dist/routers/marketplace.js +13 -3
  57. package/dist/routers/marketplace.js.map +1 -1
  58. package/dist/routers/message.d.ts +37 -4
  59. package/dist/routers/message.d.ts.map +1 -1
  60. package/dist/routers/message.js +24 -7
  61. package/dist/routers/message.js.map +1 -1
  62. package/dist/routers/module.d.ts +345 -0
  63. package/dist/routers/module.d.ts.map +1 -0
  64. package/dist/routers/module.js +82 -0
  65. package/dist/routers/module.js.map +1 -0
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routes/aiStream.js +32 -2
  75. package/dist/routes/aiStream.js.map +1 -1
  76. package/dist/seedDatabase.d.ts.map +1 -1
  77. package/dist/seedDatabase.js +107 -3
  78. package/dist/seedDatabase.js.map +1 -1
  79. package/dist/services/adminUser.d.ts +0 -1
  80. package/dist/services/adminUser.d.ts.map +1 -1
  81. package/dist/services/adminUser.js +7 -4
  82. package/dist/services/adminUser.js.map +1 -1
  83. package/dist/services/aiChatStream.d.ts +15 -0
  84. package/dist/services/aiChatStream.d.ts.map +1 -1
  85. package/dist/services/aiChatStream.js +144 -74
  86. package/dist/services/aiChatStream.js.map +1 -1
  87. package/dist/services/aiGeneration.d.ts +4 -0
  88. package/dist/services/aiGeneration.d.ts.map +1 -0
  89. package/dist/services/aiGeneration.js +88 -0
  90. package/dist/services/aiGeneration.js.map +1 -0
  91. package/dist/services/aiProposalTools.d.ts +4 -1
  92. package/dist/services/aiProposalTools.d.ts.map +1 -1
  93. package/dist/services/aiProposalTools.js +128 -3
  94. package/dist/services/aiProposalTools.js.map +1 -1
  95. package/dist/services/aiProposals.d.ts +70 -0
  96. package/dist/services/aiProposals.d.ts.map +1 -0
  97. package/dist/services/aiProposals.js +363 -0
  98. package/dist/services/aiProposals.js.map +1 -0
  99. package/dist/services/assignment.d.ts.map +1 -1
  100. package/dist/services/assignment.js +8 -3
  101. package/dist/services/assignment.js.map +1 -1
  102. package/dist/services/conversation.d.ts +1 -0
  103. package/dist/services/conversation.d.ts.map +1 -1
  104. package/dist/services/labChat.d.ts +10 -0
  105. package/dist/services/labChat.d.ts.map +1 -1
  106. package/dist/services/labChat.js +52 -3
  107. package/dist/services/labChat.js.map +1 -1
  108. package/dist/services/marketplace.d.ts +36 -0
  109. package/dist/services/marketplace.d.ts.map +1 -1
  110. package/dist/services/marketplace.js +30 -3
  111. package/dist/services/marketplace.js.map +1 -1
  112. package/dist/services/message.d.ts +24 -8
  113. package/dist/services/message.d.ts.map +1 -1
  114. package/dist/services/message.js +170 -117
  115. package/dist/services/message.js.map +1 -1
  116. package/dist/services/module.d.ts +288 -0
  117. package/dist/services/module.d.ts.map +1 -0
  118. package/dist/services/module.js +518 -0
  119. package/dist/services/module.js.map +1 -0
  120. package/dist/services/parentPortal.d.ts +14 -0
  121. package/dist/services/parentPortal.d.ts.map +1 -1
  122. package/dist/services/parentPortal.js +72 -2
  123. package/dist/services/parentPortal.js.map +1 -1
  124. package/dist/services/school.d.ts.map +1 -1
  125. package/dist/services/school.js +17 -5
  126. package/dist/services/school.js.map +1 -1
  127. package/dist/services/studentProgress.d.ts +50 -0
  128. package/dist/services/studentProgress.d.ts.map +1 -1
  129. package/dist/services/studentProgress.js +107 -2
  130. package/dist/services/studentProgress.js.map +1 -1
  131. package/dist/utils/inference.d.ts +10 -0
  132. package/dist/utils/inference.d.ts.map +1 -1
  133. package/dist/utils/inference.js +77 -2
  134. package/dist/utils/inference.js.map +1 -1
  135. package/dist/workers/processors/createRunJobWorker.js +17 -16
  136. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  137. package/package.json +1 -1
  138. package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
  139. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  140. package/prisma/schema.prisma +101 -0
  141. package/src/models/cloudStorage.ts +19 -0
  142. package/src/models/labChat.ts +34 -0
  143. package/src/models/marketplace.ts +16 -0
  144. package/src/models/module.ts +77 -0
  145. package/src/pipelines/aiLabChat.ts +79 -18
  146. package/src/pipelines/aiLabChatContract.ts +35 -0
  147. package/src/pipelines/aiNewtonChat.ts +34 -2
  148. package/src/pipelines/gradeWorksheet.ts +6 -0
  149. package/src/pipelines/labChatPrompt.ts +11 -12
  150. package/src/routers/_app.ts +2 -0
  151. package/src/routers/labChat.ts +5 -0
  152. package/src/routers/marketplace.ts +16 -0
  153. package/src/routers/message.ts +34 -5
  154. package/src/routers/module.ts +118 -0
  155. package/src/routers/parent.ts +11 -0
  156. package/src/routers/studentProgress.ts +15 -0
  157. package/src/routes/aiStream.ts +32 -0
  158. package/src/seedDatabase.ts +107 -2
  159. package/src/services/adminUser.ts +5 -2
  160. package/src/services/aiChatStream.ts +83 -7
  161. package/src/services/aiGeneration.ts +92 -0
  162. package/src/services/aiProposalTools.ts +166 -1
  163. package/src/services/aiProposals.ts +428 -0
  164. package/src/services/assignment.ts +12 -1
  165. package/src/services/labChat.ts +65 -0
  166. package/src/services/marketplace.ts +39 -0
  167. package/src/services/message.ts +205 -142
  168. package/src/services/module.ts +683 -0
  169. package/src/services/parentPortal.ts +85 -0
  170. package/src/services/school.ts +15 -3
  171. package/src/services/studentProgress.ts +135 -0
  172. package/src/utils/inference.ts +85 -0
  173. package/src/workers/processors/createRunJobWorker.ts +17 -17
  174. package/tests/unit/services/adminUser.test.ts +75 -0
  175. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
@@ -11,6 +11,7 @@ import {
11
11
  findLabChatByMember,
12
12
  findLabChatByTeacher,
13
13
  findLabChatById,
14
+ findClassContextForStarterPrompts,
14
15
  findClassMembership,
15
16
  findLabChatsByClass,
16
17
  findLabChatForPost,
@@ -203,6 +204,70 @@ export async function getLabChat(userId: string, labChatId: string) {
203
204
  };
204
205
  }
205
206
 
207
+ const GENERIC_STARTER_PROMPTS = [
208
+ "Create a quiz on this week's topic",
209
+ "Draft a worksheet with 10 practice questions",
210
+ "Plan a lesson with objectives and activities",
211
+ "Suggest an assignment with a mark scheme",
212
+ ];
213
+
214
+ const MAX_STARTER_PROMPTS = 5;
215
+
216
+ /**
217
+ * Suggest starter prompts for a class's lab chat, derived from recent
218
+ * class context (upcoming due work, recent assignments, units, subject).
219
+ * Pure heuristics — no LLM call — so it stays cheap and fast. Falls back
220
+ * to generic defaults when the class has no usable context.
221
+ */
222
+ export async function getStarterPrompts(userId: string, classId: string) {
223
+ const classContext = await findClassContextForStarterPrompts(
224
+ classId,
225
+ userId
226
+ );
227
+ if (!classContext) {
228
+ throw new TRPCError({
229
+ code: "FORBIDDEN",
230
+ message: "Not a teacher in this class",
231
+ });
232
+ }
233
+
234
+ const prompts: string[] = [];
235
+ const now = new Date();
236
+
237
+ const published = classContext.assignments.filter((a) => !a.inProgress);
238
+ const upcoming = published
239
+ .filter((a) => a.dueDate > now)
240
+ .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime())[0];
241
+ if (upcoming) {
242
+ prompts.push(
243
+ `Create a worksheet to help students prepare for "${upcoming.title}"`
244
+ );
245
+ }
246
+
247
+ const recent = published.find((a) => a.title !== upcoming?.title);
248
+ if (recent) {
249
+ prompts.push(`Draft a quiz reviewing "${recent.title}"`);
250
+ }
251
+
252
+ const unit = classContext.sections[0];
253
+ if (unit) {
254
+ prompts.push(`Plan a lesson for the unit "${unit.name}"`);
255
+ }
256
+
257
+ if (classContext.subject) {
258
+ prompts.push(
259
+ `Suggest a ${classContext.subject} assignment with a mark scheme`
260
+ );
261
+ }
262
+
263
+ for (const generic of GENERIC_STARTER_PROMPTS) {
264
+ if (prompts.length >= 3) break;
265
+ if (!prompts.includes(generic)) prompts.push(generic);
266
+ }
267
+
268
+ return { prompts: prompts.slice(0, MAX_STARTER_PROMPTS) };
269
+ }
270
+
206
271
  export async function listLabChats(userId: string, classId: string) {
207
272
  const classMembership = await findClassMembership(classId, userId);
208
273
  if (!classMembership) {
@@ -21,6 +21,7 @@ import {
21
21
  incrementMarketplaceReportCount,
22
22
  searchMarketplaceListings,
23
23
  setMarketplaceListingHidden,
24
+ updateMarketplaceListing,
24
25
  } from "../models/marketplace.js";
25
26
  import {
26
27
  findActiveSchoolAdminMembership,
@@ -659,6 +660,44 @@ export async function reportMarketplaceListing(
659
660
  return incrementMarketplaceReportCount(listingId);
660
661
  }
661
662
 
663
+ export async function updateMarketplaceListingDetails(
664
+ userId: string,
665
+ input: {
666
+ listingId: string;
667
+ title?: string;
668
+ description?: string | null;
669
+ visibility?: "SCHOOL" | "PUBLIC";
670
+ tags?: string[];
671
+ }
672
+ ) {
673
+ const listing = await findMarketplaceListingById(input.listingId);
674
+ if (!listing) {
675
+ throw new TRPCError({ code: "NOT_FOUND", message: "Listing not found" });
676
+ }
677
+ const isAuthor = listing.authorId === userId;
678
+ const isSchoolAdmin =
679
+ listing.schoolId != null &&
680
+ Boolean(await findActiveSchoolAdminMembership(listing.schoolId, userId));
681
+ if (!isAuthor && !isSchoolAdmin) {
682
+ throw new TRPCError({
683
+ code: "FORBIDDEN",
684
+ message: "Only the author or a school admin can edit this listing",
685
+ });
686
+ }
687
+ if (input.visibility === "SCHOOL" && listing.schoolId == null) {
688
+ throw new TRPCError({
689
+ code: "BAD_REQUEST",
690
+ message: "Listing has no school; it cannot use school visibility",
691
+ });
692
+ }
693
+ return updateMarketplaceListing(input.listingId, {
694
+ title: input.title,
695
+ description: input.description,
696
+ visibility: input.visibility,
697
+ tags: input.tags ? normalizeTags(input.tags) : undefined,
698
+ });
699
+ }
700
+
662
701
  export async function hideMarketplaceListing(
663
702
  userId: string,
664
703
  listingId: string,
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import type { AppTransactionClient } from "../lib/prisma.js";
6
6
  import { TRPCError } from "@trpc/server";
7
+ import { JobStatus, type AiMessageFeedback } from "@prisma/client";
8
+ import { isAIUser } from "../utils/aiUser.js";
9
+ import { enqueueRunJob } from "../workers/queues.js";
7
10
  import { prisma } from "../lib/prisma.js";
8
11
  import { chatChannel, pusher } from "../lib/pusher.js";
9
12
  import {
@@ -181,6 +184,7 @@ export async function listMessages(
181
184
  createdAt: message.createdAt,
182
185
  sender: message.sender,
183
186
  status: message.status,
187
+ aiFeedback: message.aiFeedback,
184
188
  attachments: message.attachments,
185
189
  meta: message.meta as Record<string, unknown>,
186
190
  mentions: message.mentions.map((m) => ({ user: m.user })),
@@ -512,148 +516,6 @@ export async function deleteMessage(userId: string, messageId: string) {
512
516
  return { success: true, messageId };
513
517
  }
514
518
 
515
- const CREATED_INDICES_KEY: Record<"assignment" | "worksheet" | "section", string> = {
516
- assignment: "assignments",
517
- worksheet: "worksheets",
518
- section: "sections",
519
- };
520
-
521
- const MARK_SUGGESTION_MAX_RETRIES = 5;
522
-
523
- /** Mark an AI-suggested item as created. Stores in message meta for fast reads. */
524
- export async function markSuggestionCreated(
525
- userId: string,
526
- input: {
527
- messageId: string;
528
- type: "assignment" | "worksheet" | "section";
529
- index: number;
530
- }
531
- ) {
532
- const { messageId, type, index } = input;
533
-
534
- const existingMessage = await findMessageByIdMinimal(messageId);
535
- if (!existingMessage) {
536
- throw new TRPCError({
537
- code: "NOT_FOUND",
538
- message: "Message not found",
539
- });
540
- }
541
-
542
- const membership = await findConversationMembership(
543
- existingMessage.conversationId,
544
- userId
545
- );
546
- if (!membership) {
547
- throw new TRPCError({
548
- code: "FORBIDDEN",
549
- message: "Not a member of this conversation",
550
- });
551
- }
552
-
553
- const conversationId = existingMessage.conversationId;
554
- let lastError: Error | null = null;
555
-
556
- for (let attempt = 0; attempt < MARK_SUGGESTION_MAX_RETRIES; attempt++) {
557
- const msg = await prisma.message.findUnique({
558
- where: { id: messageId },
559
- select: { meta: true },
560
- });
561
- if (!msg) {
562
- throw new TRPCError({
563
- code: "NOT_FOUND",
564
- message: "Message not found",
565
- });
566
- }
567
-
568
- const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
569
- const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
570
- const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
571
- if (typeIndices.includes(index)) return { success: true };
572
-
573
- const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
574
- const newMeta = {
575
- ...currentMeta,
576
- createdIndices: {
577
- ...createdIndices,
578
- [CREATED_INDICES_KEY[type]]: newTypeIndices,
579
- },
580
- };
581
-
582
- const newMetaJson = JSON.stringify(newMeta);
583
- const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
584
-
585
- const updatedCount =
586
- currentMetaJson === null
587
- ? await prisma.$executeRaw`
588
- UPDATE "Message" SET meta = ${newMetaJson}::jsonb
589
- WHERE id = ${messageId} AND meta IS NULL
590
- `
591
- : await prisma.$executeRaw`
592
- UPDATE "Message" SET meta = ${newMetaJson}::jsonb
593
- WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
594
- `;
595
-
596
- if (Number(updatedCount) > 0) {
597
- const updated = await prisma.message.findUnique({
598
- where: { id: messageId },
599
- include: {
600
- sender: {
601
- select: {
602
- id: true,
603
- username: true,
604
- profile: {
605
- select: { displayName: true, profilePicture: true },
606
- },
607
- },
608
- },
609
- attachments: { select: messageFileSelect },
610
- mentions: { select: { userId: true } },
611
- },
612
- });
613
-
614
- if (updated) {
615
- const mentionedUserIds = updated.mentions.map((m) => m.userId);
616
- try {
617
- await pusher.trigger(
618
- chatChannel(conversationId),
619
- "message-updated",
620
- {
621
- id: updated.id,
622
- content: updated.content,
623
- senderId: updated.senderId,
624
- conversationId: updated.conversationId,
625
- status: updated.status,
626
- createdAt: updated.createdAt,
627
- sender: updated.sender,
628
- attachments: updated.attachments ?? [],
629
- meta: newMeta,
630
- mentionedUserIds,
631
- }
632
- );
633
- } catch (error) {
634
- logger.error("Failed to broadcast suggestion status via Pusher", {
635
- error,
636
- messageId,
637
- });
638
- }
639
- }
640
- return { success: true };
641
- }
642
-
643
- lastError = new Error("Concurrent update conflict");
644
- }
645
-
646
- logger.error("markSuggestionCreated failed after retries", {
647
- messageId,
648
- type,
649
- index,
650
- error: lastError,
651
- });
652
- throw new TRPCError({
653
- code: "CONFLICT",
654
- message: "Failed to update suggestion status after retries due to concurrent updates",
655
- });
656
- }
657
519
 
658
520
  export async function markAsRead(userId: string, conversationId: string) {
659
521
  const membership = await findConversationMembership(conversationId, userId);
@@ -757,3 +619,204 @@ export async function getUnreadCount(
757
619
 
758
620
  return { unreadCount, unreadMentionCount };
759
621
  }
622
+
623
+ /**
624
+ * Regenerate an AI response in a lab or Newton chat.
625
+ *
626
+ * The existing AI bubble is reused (not deleted): the row flips back to
627
+ * PENDING with cleared content/meta/feedback so the message id stays stable
628
+ * for the client, then the matching pipeline refills it. Only the latest
629
+ * message in the conversation can be regenerated — the pipelines build
630
+ * their context from the messages preceding the placeholder.
631
+ */
632
+ export async function regenerateAIResponse(userId: string, messageId: string) {
633
+ const message = await prisma.message.findUnique({
634
+ where: { id: messageId },
635
+ select: {
636
+ id: true,
637
+ senderId: true,
638
+ conversationId: true,
639
+ status: true,
640
+ conversation: {
641
+ select: {
642
+ labChat: { select: { id: true, classId: true } },
643
+ newtonChat: { select: { id: true } },
644
+ },
645
+ },
646
+ },
647
+ });
648
+ if (!message) {
649
+ throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
650
+ }
651
+
652
+ const membership = await findConversationMembership(
653
+ message.conversationId,
654
+ userId
655
+ );
656
+ if (!membership) {
657
+ throw new TRPCError({
658
+ code: "FORBIDDEN",
659
+ message: "Not a member of this conversation",
660
+ });
661
+ }
662
+
663
+ if (!isAIUser(message.senderId)) {
664
+ throw new TRPCError({
665
+ code: "BAD_REQUEST",
666
+ message: "Only AI messages can be regenerated",
667
+ });
668
+ }
669
+
670
+ const { labChat, newtonChat } = message.conversation;
671
+ if (!labChat && !newtonChat) {
672
+ throw new TRPCError({
673
+ code: "BAD_REQUEST",
674
+ message: "This conversation has no AI pipeline to regenerate with",
675
+ });
676
+ }
677
+
678
+ if (message.status === JobStatus.PENDING) {
679
+ throw new TRPCError({
680
+ code: "BAD_REQUEST",
681
+ message: "This response is still being generated",
682
+ });
683
+ }
684
+
685
+ const latestMessage = await prisma.message.findFirst({
686
+ where: { conversationId: message.conversationId },
687
+ orderBy: { createdAt: "desc" },
688
+ select: { id: true },
689
+ });
690
+ if (latestMessage?.id !== message.id) {
691
+ throw new TRPCError({
692
+ code: "BAD_REQUEST",
693
+ message: "Only the latest AI response can be regenerated",
694
+ });
695
+ }
696
+
697
+ const reusedAiMessage = await prisma.message.update({
698
+ where: { id: message.id },
699
+ data: {
700
+ content: "",
701
+ status: JobStatus.PENDING,
702
+ meta: {},
703
+ aiFeedback: null,
704
+ },
705
+ select: {
706
+ id: true,
707
+ content: true,
708
+ senderId: true,
709
+ conversationId: true,
710
+ status: true,
711
+ createdAt: true,
712
+ },
713
+ });
714
+
715
+ // Tell the chat UI the existing AI bubble is regenerating — content
716
+ // cleared, status flipped back to PENDING, same id.
717
+ try {
718
+ await pusher.trigger(
719
+ chatChannel(message.conversationId),
720
+ "message-updated",
721
+ {
722
+ id: reusedAiMessage.id,
723
+ content: reusedAiMessage.content,
724
+ senderId: reusedAiMessage.senderId,
725
+ conversationId: reusedAiMessage.conversationId,
726
+ status: reusedAiMessage.status,
727
+ createdAt: reusedAiMessage.createdAt,
728
+ sender: {
729
+ id: reusedAiMessage.senderId,
730
+ username: "Newton_AI",
731
+ profile: { displayName: "Newton AI", profilePicture: null },
732
+ },
733
+ mentionedUserIds: [],
734
+ meta: {},
735
+ attachments: [],
736
+ }
737
+ );
738
+ } catch (error) {
739
+ logger.error("Failed to broadcast AI regenerate reset", {
740
+ error,
741
+ messageId,
742
+ });
743
+ }
744
+
745
+ try {
746
+ if (labChat) {
747
+ await enqueueRunJob(
748
+ "AI_LAB_CHAT",
749
+ { conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
750
+ { classId: labChat.classId, userId }
751
+ );
752
+ } else {
753
+ await enqueueRunJob(
754
+ "AI_NEWTON_CHAT",
755
+ { conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
756
+ { userId }
757
+ );
758
+ }
759
+ } catch (error) {
760
+ logger.error("Failed to enqueue AI regeneration", {
761
+ error,
762
+ messageId,
763
+ conversationId: message.conversationId,
764
+ });
765
+ throw new TRPCError({
766
+ code: "INTERNAL_SERVER_ERROR",
767
+ message: "Failed to start regeneration",
768
+ });
769
+ }
770
+
771
+ return { success: true, messageId: reusedAiMessage.id };
772
+ }
773
+
774
+ /** Record (or clear) thumbs up/down feedback on an AI message. */
775
+ export async function setAIMessageFeedback(
776
+ userId: string,
777
+ input: {
778
+ messageId: string;
779
+ feedback: AiMessageFeedback | null;
780
+ }
781
+ ) {
782
+ const message = await prisma.message.findUnique({
783
+ where: { id: input.messageId },
784
+ select: { id: true, senderId: true, conversationId: true, status: true },
785
+ });
786
+ if (!message) {
787
+ throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
788
+ }
789
+
790
+ const membership = await findConversationMembership(
791
+ message.conversationId,
792
+ userId
793
+ );
794
+ if (!membership) {
795
+ throw new TRPCError({
796
+ code: "FORBIDDEN",
797
+ message: "Not a member of this conversation",
798
+ });
799
+ }
800
+
801
+ if (!isAIUser(message.senderId)) {
802
+ throw new TRPCError({
803
+ code: "BAD_REQUEST",
804
+ message: "Feedback can only be left on AI messages",
805
+ });
806
+ }
807
+
808
+ if (message.status !== JobStatus.COMPLETED) {
809
+ throw new TRPCError({
810
+ code: "BAD_REQUEST",
811
+ message: "Feedback can only be left on completed AI responses",
812
+ });
813
+ }
814
+
815
+ const updated = await prisma.message.update({
816
+ where: { id: message.id },
817
+ data: { aiFeedback: input.feedback },
818
+ select: { id: true, aiFeedback: true },
819
+ });
820
+
821
+ return { success: true, messageId: updated.id, feedback: updated.aiFeedback };
822
+ }