@studious-lms/server 1.26.0 → 1.28.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 (222) 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/index.d.ts.map +1 -1
  5. package/dist/index.js +31 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/instrument.js +9 -4
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/fileUpload.d.ts +15 -0
  10. package/dist/lib/fileUpload.d.ts.map +1 -1
  11. package/dist/lib/fileUpload.js +43 -2
  12. package/dist/lib/fileUpload.js.map +1 -1
  13. package/dist/models/cloudStorage.d.ts +11 -0
  14. package/dist/models/cloudStorage.d.ts.map +1 -1
  15. package/dist/models/cloudStorage.js +18 -2
  16. package/dist/models/cloudStorage.js.map +1 -1
  17. package/dist/models/conversation.d.ts +1 -0
  18. package/dist/models/conversation.d.ts.map +1 -1
  19. package/dist/models/labChat.d.ts +40 -0
  20. package/dist/models/labChat.d.ts.map +1 -1
  21. package/dist/models/labChat.js +32 -2
  22. package/dist/models/labChat.js.map +1 -1
  23. package/dist/models/message.d.ts +3 -0
  24. package/dist/models/message.d.ts.map +1 -1
  25. package/dist/pipelines/aiLabChat.d.ts +2 -0
  26. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +60 -16
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  30. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.js +30 -2
  32. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +30 -5
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/generateVisualization.d.ts +33 -0
  37. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  38. package/dist/pipelines/generateVisualization.js +79 -0
  39. package/dist/pipelines/generateVisualization.js.map +1 -0
  40. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +13 -14
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +1118 -70
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/_app.js +4 -2
  47. package/dist/routers/_app.js.map +1 -1
  48. package/dist/routers/admin.d.ts +297 -1
  49. package/dist/routers/admin.d.ts.map +1 -1
  50. package/dist/routers/admin.js +43 -2
  51. package/dist/routers/admin.js.map +1 -1
  52. package/dist/routers/class.d.ts.map +1 -1
  53. package/dist/routers/class.js +16 -3
  54. package/dist/routers/class.js.map +1 -1
  55. package/dist/routers/conversation.d.ts +1 -0
  56. package/dist/routers/conversation.d.ts.map +1 -1
  57. package/dist/routers/labChat.d.ts +10 -0
  58. package/dist/routers/labChat.d.ts.map +1 -1
  59. package/dist/routers/labChat.js +6 -3
  60. package/dist/routers/labChat.js.map +1 -1
  61. package/dist/routers/message.d.ts +37 -4
  62. package/dist/routers/message.d.ts.map +1 -1
  63. package/dist/routers/message.js +24 -7
  64. package/dist/routers/message.js.map +1 -1
  65. package/dist/routers/module.d.ts +1 -1
  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/routers/visualization.d.ts +124 -0
  75. package/dist/routers/visualization.d.ts.map +1 -0
  76. package/dist/routers/visualization.js +41 -0
  77. package/dist/routers/visualization.js.map +1 -0
  78. package/dist/routes/aiStream.js +32 -2
  79. package/dist/routes/aiStream.js.map +1 -1
  80. package/dist/seedDatabase.d.ts.map +1 -1
  81. package/dist/seedDatabase.js +107 -3
  82. package/dist/seedDatabase.js.map +1 -1
  83. package/dist/services/adminClass.d.ts +153 -0
  84. package/dist/services/adminClass.d.ts.map +1 -0
  85. package/dist/services/adminClass.js +196 -0
  86. package/dist/services/adminClass.js.map +1 -0
  87. package/dist/services/adminInstitution.d.ts +2 -0
  88. package/dist/services/adminInstitution.d.ts.map +1 -1
  89. package/dist/services/adminInstitution.js +11 -4
  90. package/dist/services/adminInstitution.js.map +1 -1
  91. package/dist/services/adminMetrics.d.ts +8 -0
  92. package/dist/services/adminMetrics.d.ts.map +1 -1
  93. package/dist/services/adminMetrics.js +30 -3
  94. package/dist/services/adminMetrics.js.map +1 -1
  95. package/dist/services/adminSpam.d.ts +76 -0
  96. package/dist/services/adminSpam.d.ts.map +1 -0
  97. package/dist/services/adminSpam.js +163 -0
  98. package/dist/services/adminSpam.js.map +1 -0
  99. package/dist/services/adminSql.d.ts +36 -0
  100. package/dist/services/adminSql.d.ts.map +1 -0
  101. package/dist/services/adminSql.js +172 -0
  102. package/dist/services/adminSql.js.map +1 -0
  103. package/dist/services/adminTelemetry.d.ts +56 -0
  104. package/dist/services/adminTelemetry.d.ts.map +1 -0
  105. package/dist/services/adminTelemetry.js +147 -0
  106. package/dist/services/adminTelemetry.js.map +1 -0
  107. package/dist/services/adminUser.d.ts +45 -1
  108. package/dist/services/adminUser.d.ts.map +1 -1
  109. package/dist/services/adminUser.js +53 -13
  110. package/dist/services/adminUser.js.map +1 -1
  111. package/dist/services/aiChatStream.d.ts +15 -0
  112. package/dist/services/aiChatStream.d.ts.map +1 -1
  113. package/dist/services/aiChatStream.js +144 -74
  114. package/dist/services/aiChatStream.js.map +1 -1
  115. package/dist/services/aiGeneration.d.ts +4 -0
  116. package/dist/services/aiGeneration.d.ts.map +1 -0
  117. package/dist/services/aiGeneration.js +88 -0
  118. package/dist/services/aiGeneration.js.map +1 -0
  119. package/dist/services/aiProposalTools.d.ts +4 -1
  120. package/dist/services/aiProposalTools.d.ts.map +1 -1
  121. package/dist/services/aiProposalTools.js +128 -3
  122. package/dist/services/aiProposalTools.js.map +1 -1
  123. package/dist/services/aiProposals.d.ts +70 -0
  124. package/dist/services/aiProposals.d.ts.map +1 -0
  125. package/dist/services/aiProposals.js +363 -0
  126. package/dist/services/aiProposals.js.map +1 -0
  127. package/dist/services/auth.d.ts.map +1 -1
  128. package/dist/services/auth.js +10 -4
  129. package/dist/services/auth.js.map +1 -1
  130. package/dist/services/conversation.d.ts +1 -0
  131. package/dist/services/conversation.d.ts.map +1 -1
  132. package/dist/services/fees.d.ts.map +1 -1
  133. package/dist/services/fees.js +7 -3
  134. package/dist/services/fees.js.map +1 -1
  135. package/dist/services/labChat.d.ts +10 -0
  136. package/dist/services/labChat.d.ts.map +1 -1
  137. package/dist/services/labChat.js +52 -3
  138. package/dist/services/labChat.js.map +1 -1
  139. package/dist/services/message.d.ts +24 -8
  140. package/dist/services/message.d.ts.map +1 -1
  141. package/dist/services/message.js +170 -117
  142. package/dist/services/message.js.map +1 -1
  143. package/dist/services/module.d.ts.map +1 -1
  144. package/dist/services/module.js +23 -4
  145. package/dist/services/module.js.map +1 -1
  146. package/dist/services/parentPortal.d.ts +14 -0
  147. package/dist/services/parentPortal.d.ts.map +1 -1
  148. package/dist/services/parentPortal.js +72 -2
  149. package/dist/services/parentPortal.js.map +1 -1
  150. package/dist/services/studentProgress.d.ts +50 -0
  151. package/dist/services/studentProgress.d.ts.map +1 -1
  152. package/dist/services/studentProgress.js +107 -2
  153. package/dist/services/studentProgress.js.map +1 -1
  154. package/dist/services/visualization.d.ts +72 -0
  155. package/dist/services/visualization.d.ts.map +1 -0
  156. package/dist/services/visualization.js +122 -0
  157. package/dist/services/visualization.js.map +1 -0
  158. package/dist/utils/inference.d.ts +10 -0
  159. package/dist/utils/inference.d.ts.map +1 -1
  160. package/dist/utils/inference.js +77 -2
  161. package/dist/utils/inference.js.map +1 -1
  162. package/dist/utils/sentryTrpc.d.ts +3 -0
  163. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  164. package/dist/utils/sentryTrpc.js +26 -0
  165. package/dist/utils/sentryTrpc.js.map +1 -0
  166. package/dist/workers/processors/createRunJobWorker.js +17 -16
  167. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  168. package/package.json +1 -1
  169. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  170. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  171. package/prisma/schema.prisma +32 -0
  172. package/src/index.ts +30 -4
  173. package/src/instrument.ts +8 -2
  174. package/src/lib/fileUpload.ts +49 -0
  175. package/src/models/cloudStorage.ts +19 -0
  176. package/src/models/labChat.ts +34 -0
  177. package/src/pipelines/aiLabChat.ts +79 -18
  178. package/src/pipelines/aiLabChatContract.ts +35 -0
  179. package/src/pipelines/aiNewtonChat.ts +34 -2
  180. package/src/pipelines/generateVisualization.ts +107 -0
  181. package/src/pipelines/labChatPrompt.ts +11 -12
  182. package/src/routers/_app.ts +2 -0
  183. package/src/routers/admin.ts +72 -0
  184. package/src/routers/class.ts +14 -1
  185. package/src/routers/labChat.ts +5 -0
  186. package/src/routers/message.ts +34 -5
  187. package/src/routers/parent.ts +11 -0
  188. package/src/routers/studentProgress.ts +15 -0
  189. package/src/routers/visualization.ts +60 -0
  190. package/src/routes/aiStream.ts +32 -0
  191. package/src/seedDatabase.ts +107 -2
  192. package/src/services/adminClass.ts +222 -0
  193. package/src/services/adminInstitution.ts +9 -2
  194. package/src/services/adminMetrics.ts +35 -0
  195. package/src/services/adminSpam.ts +224 -0
  196. package/src/services/adminSql.ts +214 -0
  197. package/src/services/adminTelemetry.ts +194 -0
  198. package/src/services/adminUser.ts +66 -11
  199. package/src/services/aiChatStream.ts +83 -7
  200. package/src/services/aiGeneration.ts +92 -0
  201. package/src/services/aiProposalTools.ts +166 -1
  202. package/src/services/aiProposals.ts +428 -0
  203. package/src/services/auth.ts +8 -2
  204. package/src/services/fees.ts +5 -1
  205. package/src/services/labChat.ts +65 -0
  206. package/src/services/message.ts +205 -142
  207. package/src/services/module.ts +29 -2
  208. package/src/services/parentPortal.ts +85 -0
  209. package/src/services/studentProgress.ts +135 -0
  210. package/src/services/visualization.ts +147 -0
  211. package/src/utils/inference.ts +85 -0
  212. package/src/utils/sentryTrpc.ts +25 -0
  213. package/src/workers/processors/createRunJobWorker.ts +17 -17
  214. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  215. package/tests/unit/services/adminClass.test.ts +102 -0
  216. package/tests/unit/services/adminSpam.test.ts +108 -0
  217. package/tests/unit/services/adminSql.test.ts +56 -0
  218. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  219. package/tests/unit/services/adminUser.test.ts +77 -0
  220. package/tests/unit/services/visualization.test.ts +130 -0
  221. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
  222. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -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
+ }
@@ -182,6 +182,7 @@ const CONTENT_TYPES: ModuleItemType[] = [
182
182
  ModuleItemType.FILE,
183
183
  ModuleItemType.ANNOUNCEMENT,
184
184
  ModuleItemType.DISCUSSION,
185
+ ModuleItemType.VISUALIZATION,
185
186
  ];
186
187
 
187
188
  const SUBMITTABLE_TYPES: ModuleItemType[] = [
@@ -235,7 +236,9 @@ async function validateItemInput(
235
236
  ? await prisma.announcement.findFirst({ where: { id: itemId, classId } })
236
237
  : itemType === ModuleItemType.DISCUSSION
237
238
  ? await prisma.discussion.findFirst({ where: { id: itemId, classId } })
238
- : await prisma.file.findUnique({ where: { id: itemId } });
239
+ : itemType === ModuleItemType.VISUALIZATION
240
+ ? await prisma.visualization.findFirst({ where: { id: itemId, classId } })
241
+ : await prisma.file.findUnique({ where: { id: itemId } });
239
242
  if (!exists) {
240
243
  throw new TRPCError({
241
244
  code: "NOT_FOUND",
@@ -437,11 +440,35 @@ function isModuleComplete(
437
440
  * completion/accessibility and per-module progress percentages.
438
441
  */
439
442
  export async function getStudentModulesState(classId: string, studentId: string) {
440
- const [modules, progress] = await Promise.all([
443
+ const [allModules, progress] = await Promise.all([
441
444
  findModulesByClassId(classId, true),
442
445
  findStudentProgressForClass(classId, studentId),
443
446
  ]);
444
447
 
448
+ // Hide visualisations the teacher hasn't published yet.
449
+ const visualizationIds = allModules
450
+ .flatMap((m) => m.items)
451
+ .filter((i) => i.itemType === ModuleItemType.VISUALIZATION && i.itemId)
452
+ .map((i) => i.itemId as string);
453
+ const publishedVisualizationIds = new Set(
454
+ visualizationIds.length
455
+ ? (
456
+ await prisma.visualization.findMany({
457
+ where: { id: { in: visualizationIds }, classId, published: true },
458
+ select: { id: true },
459
+ })
460
+ ).map((v) => v.id)
461
+ : []
462
+ );
463
+ const modules = allModules.map((m) => ({
464
+ ...m,
465
+ items: m.items.filter(
466
+ (i) =>
467
+ i.itemType !== ModuleItemType.VISUALIZATION ||
468
+ (!!i.itemId && publishedVisualizationIds.has(i.itemId))
469
+ ),
470
+ }));
471
+
445
472
  const completedItemIds = new Set(
446
473
  progress
447
474
  .filter((p) => p.status === ModuleItemProgressStatus.COMPLETE)
@@ -85,6 +85,91 @@ export async function linkParentToStudent(
85
85
  return { link };
86
86
  }
87
87
 
88
+ export async function addParentToStudent(
89
+ userId: string,
90
+ input: { schoolId: string; studentUserId: string; parentIdentifier: string }
91
+ ) {
92
+ await requireSchoolAdmin(input.schoolId, userId);
93
+
94
+ const studentMembership = await findSchoolMembership(
95
+ input.schoolId,
96
+ input.studentUserId
97
+ );
98
+ if (
99
+ !studentMembership ||
100
+ studentMembership.status !== "ACTIVE" ||
101
+ studentMembership.role !== "STUDENT"
102
+ ) {
103
+ throw new TRPCError({
104
+ code: "BAD_REQUEST",
105
+ message: "studentUserId must be an active STUDENT member",
106
+ });
107
+ }
108
+
109
+ const identifier = input.parentIdentifier.trim();
110
+ const parentUser = await prisma.user.findFirst({
111
+ where: {
112
+ OR: [{ email: identifier }, { username: identifier }],
113
+ },
114
+ select: { id: true },
115
+ });
116
+ if (!parentUser) {
117
+ throw new TRPCError({
118
+ code: "NOT_FOUND",
119
+ message: "No account found with that email or username",
120
+ });
121
+ }
122
+ if (parentUser.id === input.studentUserId) {
123
+ throw new TRPCError({
124
+ code: "BAD_REQUEST",
125
+ message: "A student cannot be their own parent",
126
+ });
127
+ }
128
+
129
+ const parentMembership = await findSchoolMembership(
130
+ input.schoolId,
131
+ parentUser.id
132
+ );
133
+ if (!parentMembership) {
134
+ await prisma.schoolMembership.create({
135
+ data: {
136
+ schoolId: input.schoolId,
137
+ userId: parentUser.id,
138
+ role: "PARENT",
139
+ status: "ACTIVE",
140
+ },
141
+ });
142
+ } else if (parentMembership.role !== "PARENT") {
143
+ throw new TRPCError({
144
+ code: "BAD_REQUEST",
145
+ message: "That account already belongs to this school with another role",
146
+ });
147
+ } else if (parentMembership.status !== "ACTIVE") {
148
+ throw new TRPCError({
149
+ code: "BAD_REQUEST",
150
+ message: "Parent must be an active school member",
151
+ });
152
+ }
153
+
154
+ const link = await prisma.parentStudentLink.upsert({
155
+ where: {
156
+ schoolId_parentUserId_studentUserId: {
157
+ schoolId: input.schoolId,
158
+ parentUserId: parentUser.id,
159
+ studentUserId: input.studentUserId,
160
+ },
161
+ },
162
+ update: {},
163
+ create: {
164
+ schoolId: input.schoolId,
165
+ parentUserId: parentUser.id,
166
+ studentUserId: input.studentUserId,
167
+ },
168
+ });
169
+
170
+ return { link };
171
+ }
172
+
88
173
  export async function unlinkParentFromStudent(
89
174
  userId: string,
90
175
  input: { schoolId: string; parentUserId: string; studentUserId: string }
@@ -6,6 +6,7 @@ import type { AssignmentType } from "@prisma/client";
6
6
  import { z } from "zod";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { isTeacherInClass } from "../models/class.js";
9
+ import { createAssignmentRecord } from "./assignment.js";
9
10
  import { inference } from "../utils/inference.js";
10
11
  import { logger } from "../utils/logger.js";
11
12
  import {
@@ -617,6 +618,140 @@ export async function dismissStudentRecommendation(
617
618
  return { success: true };
618
619
  }
619
620
 
621
+ const followUpAssignmentSchema = z.object({
622
+ title: z.string().min(1).max(200),
623
+ instructions: z.string().min(1),
624
+ });
625
+
626
+ export type FollowUpAssignmentDraft = z.infer<typeof followUpAssignmentSchema>;
627
+
628
+ export async function generateFollowUpAssignment(
629
+ viewerId: string,
630
+ input: {
631
+ classId: string;
632
+ studentId: string;
633
+ submissionId: string;
634
+ prompt: string;
635
+ dueDate?: string;
636
+ },
637
+ ) {
638
+ const teacherInClass = await isTeacherInClass(input.classId, viewerId);
639
+ if (!teacherInClass) {
640
+ throw new TRPCError({
641
+ code: "UNAUTHORIZED",
642
+ message: "Only teachers can generate follow-up assignments",
643
+ });
644
+ }
645
+
646
+ const submission = await prisma.submission.findFirst({
647
+ where: {
648
+ id: input.submissionId,
649
+ studentId: input.studentId,
650
+ assignment: { classId: input.classId },
651
+ },
652
+ select: {
653
+ id: true,
654
+ gradeReceived: true,
655
+ teacherComments: true,
656
+ student: {
657
+ select: {
658
+ id: true,
659
+ username: true,
660
+ profile: { select: { displayName: true } },
661
+ },
662
+ },
663
+ assignment: {
664
+ select: {
665
+ id: true,
666
+ title: true,
667
+ instructions: true,
668
+ type: true,
669
+ maxGrade: true,
670
+ weight: true,
671
+ section: { select: { id: true, name: true } },
672
+ },
673
+ },
674
+ },
675
+ });
676
+
677
+ if (!submission) {
678
+ throw new TRPCError({
679
+ code: "NOT_FOUND",
680
+ message: "Submission not found for this student in this class",
681
+ });
682
+ }
683
+
684
+ const displayName =
685
+ submission.student.profile?.displayName ?? submission.student.username;
686
+ const percentage =
687
+ submission.gradeReceived != null && submission.assignment.maxGrade
688
+ ? Math.round(
689
+ (submission.gradeReceived / submission.assignment.maxGrade) * 1000,
690
+ ) / 10
691
+ : null;
692
+
693
+ const draft = await inference<FollowUpAssignmentDraft>(
694
+ [
695
+ {
696
+ role: "system" as const,
697
+ content:
698
+ "You are an experienced teacher creating a targeted follow-up assignment for one student. Ground the task in the source assignment and the teacher's request. Do not fabricate material beyond the source assignment's topic. Return a concise title and clear, student-facing instructions.",
699
+ },
700
+ {
701
+ role: "user" as const,
702
+ content: JSON.stringify({
703
+ student: { displayName },
704
+ sourceAssignment: {
705
+ title: submission.assignment.title,
706
+ instructions: submission.assignment.instructions,
707
+ type: submission.assignment.type,
708
+ maxGrade: submission.assignment.maxGrade,
709
+ section: submission.assignment.section?.name ?? null,
710
+ },
711
+ submission: {
712
+ gradeReceived: submission.gradeReceived,
713
+ percentage,
714
+ teacherFeedback: submission.teacherComments,
715
+ },
716
+ }),
717
+ },
718
+ { role: "user" as const, content: input.prompt },
719
+ ],
720
+ followUpAssignmentSchema,
721
+ {
722
+ reason: "student_progress_follow_up_assignment",
723
+ metadata: {
724
+ viewerId,
725
+ classId: input.classId,
726
+ studentId: input.studentId,
727
+ submissionId: input.submissionId,
728
+ },
729
+ attribution: { userId: viewerId, classId: input.classId },
730
+ },
731
+ );
732
+
733
+ const dueDate =
734
+ input.dueDate ??
735
+ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
736
+
737
+ const assignment = await createAssignmentRecord(viewerId, {
738
+ classId: input.classId,
739
+ title: draft.title,
740
+ instructions: draft.instructions,
741
+ dueDate,
742
+ studentIds: [input.studentId],
743
+ maxGrade: submission.assignment.maxGrade ?? 100,
744
+ graded: true,
745
+ weight: submission.assignment.weight || 1,
746
+ sectionId: submission.assignment.section?.id ?? undefined,
747
+ type: normalizeAssignmentType(submission.assignment.type),
748
+ acceptExtendedResponse: true,
749
+ sourceAssignmentId: submission.assignment.id,
750
+ });
751
+
752
+ return { assignment, draft };
753
+ }
754
+
620
755
  function buildProgressSummary(submissions: GradeSubmission[]) {
621
756
  return submissions.map((submission) => ({
622
757
  title: submission.assignment.title,