@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
@@ -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 }
@@ -662,6 +662,7 @@ export async function getStudentTimeline(
662
662
  where: {
663
663
  userId: input.studentId,
664
664
  startTime: range,
665
+ OR: [{ classId: null }, { class: { schoolId: input.schoolId } }],
665
666
  },
666
667
  select: {
667
668
  id: true,
@@ -678,6 +679,7 @@ export async function getStudentTimeline(
678
679
  prisma.assignment.findMany({
679
680
  where: {
680
681
  class: {
682
+ schoolId: input.schoolId,
681
683
  students: { some: { id: input.studentId } },
682
684
  },
683
685
  template: false,
@@ -840,6 +842,7 @@ export async function getMemberTimeline(
840
842
  where: {
841
843
  userId: input.memberId,
842
844
  startTime: range,
845
+ OR: [{ classId: null }, { class: { schoolId: input.schoolId } }],
843
846
  },
844
847
  select: {
845
848
  id: true,
@@ -855,7 +858,10 @@ export async function getMemberTimeline(
855
858
  }),
856
859
  prisma.assignment.findMany({
857
860
  where: {
858
- class: { students: { some: { id: input.memberId } } },
861
+ class: {
862
+ schoolId: input.schoolId,
863
+ students: { some: { id: input.memberId } },
864
+ },
859
865
  template: false,
860
866
  dueDate: range,
861
867
  },
@@ -947,8 +953,14 @@ export async function getMemberTimeline(
947
953
  prisma.event.findMany({
948
954
  where: {
949
955
  OR: [
950
- { userId: input.memberId },
951
- { class: { teachers: { some: { id: input.memberId } } } },
956
+ { userId: input.memberId, classId: null },
957
+ { userId: input.memberId, class: { schoolId: input.schoolId } },
958
+ {
959
+ class: {
960
+ schoolId: input.schoolId,
961
+ teachers: { some: { id: input.memberId } },
962
+ },
963
+ },
952
964
  ],
953
965
  startTime: range,
954
966
  },
@@ -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,
@@ -476,6 +476,77 @@ export async function completePendingAIMessage(
476
476
  };
477
477
  }
478
478
 
479
+ /**
480
+ * Persist the partial output of a cancelled generation and flip the AI
481
+ * message to CANCELLED. Whatever text/tool parts streamed before the stop
482
+ * are kept on the row (content + meta) so they survive reloads. Broadcasts
483
+ * `message-updated` so clients patch the bubble in place.
484
+ */
485
+ export async function cancelPendingAIMessage(
486
+ messageId: string,
487
+ content: string,
488
+ options: {
489
+ meta?: Record<string, any>;
490
+ customSender?: AIMessageSenderInfo;
491
+ } = {},
492
+ ): Promise<void> {
493
+ try {
494
+ const updated = await prisma.message.update({
495
+ where: { id: messageId },
496
+ data: {
497
+ content,
498
+ status: JobStatus.CANCELLED,
499
+ ...(options.meta !== undefined && {
500
+ meta: options.meta as Prisma.InputJsonValue,
501
+ }),
502
+ },
503
+ include: {
504
+ attachments: true,
505
+ },
506
+ });
507
+
508
+ logger.info('AI message cancelled', {
509
+ messageId: updated.id,
510
+ conversationId: updated.conversationId,
511
+ contentLength: content.length,
512
+ });
513
+
514
+ const senderInfo = buildAISenderInfo(options.customSender);
515
+
516
+ try {
517
+ await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
518
+ id: updated.id,
519
+ content: updated.content,
520
+ senderId: updated.senderId,
521
+ conversationId: updated.conversationId,
522
+ status: updated.status,
523
+ createdAt: updated.createdAt,
524
+ sender: senderInfo,
525
+ mentionedUserIds: [],
526
+ meta: updated.meta ?? {},
527
+ attachments: updated.attachments.map((attachment) => ({
528
+ id: attachment.id,
529
+ attachmentId: attachment.id,
530
+ name: attachment.name,
531
+ type: attachment.type,
532
+ size: attachment.size,
533
+ path: attachment.path,
534
+ })),
535
+ });
536
+ } catch (broadcastError) {
537
+ logger.error('Failed to broadcast cancelled AI message:', {
538
+ error: broadcastError,
539
+ messageId,
540
+ });
541
+ }
542
+ } catch (error) {
543
+ logger.error('Failed to mark AI message as cancelled:', {
544
+ error,
545
+ messageId,
546
+ });
547
+ }
548
+ }
549
+
479
550
  /**
480
551
  * Flip a PENDING AI message to FAILED and broadcast the status change.
481
552
  * Does NOT throw — failure paths in the pipelines call this in catch blocks
@@ -486,6 +557,20 @@ export async function failPendingAIMessage(
486
557
  options: { reason?: string } = {},
487
558
  ): Promise<void> {
488
559
  try {
560
+ const current = await prisma.message.findUnique({
561
+ where: { id: messageId },
562
+ select: { status: true },
563
+ });
564
+ // A message the user already cancelled must stay CANCELLED — late
565
+ // failures from the aborted pipeline are a consequence of the cancel.
566
+ if (current?.status === JobStatus.CANCELLED) {
567
+ logger.info('Skipping FAILED flip; AI message already cancelled', {
568
+ messageId,
569
+ reason: options.reason,
570
+ });
571
+ return;
572
+ }
573
+
489
574
  const updated = await prisma.message.update({
490
575
  where: { id: messageId },
491
576
  data: { status: JobStatus.FAILED },
@@ -91,6 +91,14 @@ export function createRunJobWorker<T extends RunJobType>({
91
91
  return existing.result;
92
92
  }
93
93
 
94
+ if (existing.status === 'CANCELLED') {
95
+ logger.info(`${name} RunJob cancelled before start; skipping`, {
96
+ runJobId,
97
+ bullJobId: job.id,
98
+ });
99
+ return existing.result;
100
+ }
101
+
94
102
  await prisma.runJob.update({
95
103
  where: { id: runJobId },
96
104
  data: {
@@ -108,8 +116,10 @@ export function createRunJobWorker<T extends RunJobType>({
108
116
  classId: existing.classId ?? null,
109
117
  });
110
118
 
111
- await prisma.runJob.update({
112
- where: { id: runJobId },
119
+ // `updateMany` with a status guard so a cancellation that landed while
120
+ // the run was in flight is not overwritten with SUCCEEDED.
121
+ await prisma.runJob.updateMany({
122
+ where: { id: runJobId, status: { not: 'CANCELLED' } },
113
123
  data: {
114
124
  status: 'SUCCEEDED',
115
125
  finishedAt: new Date(),
@@ -146,29 +156,19 @@ export function createRunJobWorker<T extends RunJobType>({
146
156
 
147
157
  if (!runJobId) return;
148
158
 
159
+ // Status guard: don't let a late failure (e.g. from an aborted stream)
160
+ // overwrite a RunJob the user already cancelled.
149
161
  await prisma.runJob
150
- .update({
151
- where: { id: runJobId },
162
+ .updateMany({
163
+ where: { id: runJobId, status: { not: 'CANCELLED' } },
152
164
  data: {
153
165
  error: err.message,
154
166
  ...(isFinalAttempt
155
- ? { status: 'FAILED', finishedAt: new Date() }
167
+ ? { status: 'FAILED' as const, finishedAt: new Date() }
156
168
  : {}),
157
169
  },
158
170
  })
159
171
  .catch((updateError) => {
160
- // P2025 = record not found. The RunJob row is gone (orphaned job from a
161
- // shared/reset Redis), so there's nothing to flag as FAILED — log
162
- // quietly instead of as an error to avoid noise.
163
- if (
164
- updateError instanceof Prisma.PrismaClientKnownRequestError &&
165
- updateError.code === 'P2025'
166
- ) {
167
- logger.warn(`${name} failure not recorded; RunJob no longer exists`, {
168
- runJobId,
169
- });
170
- return;
171
- }
172
172
  logger.error(`Failed to record ${name} failure on RunJob`, {
173
173
  runJobId,
174
174
  error:
@@ -0,0 +1,75 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+
4
+ const prismaMocks = vi.hoisted(() => ({
5
+ user: { findUnique: vi.fn() },
6
+ }));
7
+
8
+ vi.mock('../../../src/lib/prisma.js', () => ({
9
+ prisma: prismaMocks,
10
+ }));
11
+
12
+ import { getUserForAdmin } from '../../../src/services/adminUser.js';
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ describe('getUserForAdmin', () => {
19
+ test('does not select session ids (they are bearer tokens) and only returns AUTH sessions', async () => {
20
+ prismaMocks.user.findUnique.mockResolvedValue({
21
+ id: 'user-1',
22
+ username: 'ada',
23
+ email: 'ada@example.com',
24
+ verified: true,
25
+ termsAcceptedAt: null,
26
+ termsVersion: null,
27
+ profile: null,
28
+ school: null,
29
+ schoolMemberships: [],
30
+ sessions: [
31
+ {
32
+ type: 'AUTH',
33
+ createdAt: new Date('2026-08-01T00:00:00Z'),
34
+ expiresAt: new Date('2026-09-01T00:00:00Z'),
35
+ },
36
+ ],
37
+ _count: {
38
+ teacherIn: 0,
39
+ studentIn: 0,
40
+ sessions: 1,
41
+ submissions: 0,
42
+ files: 0,
43
+ },
44
+ });
45
+
46
+ const user = await getUserForAdmin('user-1');
47
+
48
+ expect(prismaMocks.user.findUnique).toHaveBeenCalledWith(
49
+ expect.objectContaining({
50
+ where: { id: 'user-1' },
51
+ select: expect.objectContaining({
52
+ sessions: {
53
+ where: { type: 'AUTH' },
54
+ orderBy: { createdAt: 'desc' },
55
+ take: 10,
56
+ select: { type: true, createdAt: true, expiresAt: true },
57
+ },
58
+ _count: {
59
+ select: expect.objectContaining({
60
+ sessions: { where: { type: 'AUTH' } },
61
+ }),
62
+ },
63
+ }),
64
+ }),
65
+ );
66
+ expect(user.sessions[0]).not.toHaveProperty('id');
67
+ });
68
+
69
+ test('throws NOT_FOUND when the user is missing', async () => {
70
+ prismaMocks.user.findUnique.mockResolvedValue(null);
71
+ await expect(getUserForAdmin('missing')).rejects.toMatchObject({
72
+ code: 'NOT_FOUND',
73
+ } satisfies Partial<TRPCError>);
74
+ });
75
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+ import type { Request } from 'express';
4
+ import { assertAdminPanelPassword } from '../../../src/utils/adminPanelAccess.js';
5
+
6
+ function reqWithHeader(value: string | string[] | undefined): Request {
7
+ return { headers: { 'x-admin-password': value } } as unknown as Request;
8
+ }
9
+
10
+ describe('assertAdminPanelPassword', () => {
11
+ test('accepts the configured admin password header', () => {
12
+ expect(() => assertAdminPanelPassword(reqWithHeader('alan6767'))).not.toThrow();
13
+ });
14
+
15
+ test('rejects a missing or wrong header with FORBIDDEN', () => {
16
+ expect(() => assertAdminPanelPassword(reqWithHeader(undefined))).toThrow(
17
+ TRPCError,
18
+ );
19
+ try {
20
+ assertAdminPanelPassword(reqWithHeader('nope'));
21
+ } catch (error) {
22
+ expect(error).toBeInstanceOf(TRPCError);
23
+ expect((error as TRPCError).code).toBe('FORBIDDEN');
24
+ }
25
+ });
26
+ });