@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.
- package/.env.example +3 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- package/dist/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/marketplace.d.ts +35 -0
- package/dist/models/marketplace.d.ts.map +1 -1
- package/dist/models/marketplace.js +9 -2
- package/dist/models/marketplace.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/module.d.ts +228 -0
- package/dist/models/module.d.ts.map +1 -0
- package/dist/models/module.js +69 -0
- package/dist/models/module.js.map +1 -0
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +4 -2
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1185 -215
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +0 -1
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/marketplace.d.ts +41 -0
- package/dist/routers/marketplace.d.ts.map +1 -1
- package/dist/routers/marketplace.js +13 -3
- package/dist/routers/marketplace.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +345 -0
- package/dist/routers/module.d.ts.map +1 -0
- package/dist/routers/module.js +82 -0
- package/dist/routers/module.js.map +1 -0
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminUser.d.ts +0 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +7 -4
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +8 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/marketplace.d.ts +36 -0
- package/dist/services/marketplace.d.ts.map +1 -1
- package/dist/services/marketplace.js +30 -3
- package/dist/services/marketplace.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.js.map +1 -1
- package/dist/services/module.d.ts +288 -0
- package/dist/services/module.d.ts.map +1 -0
- package/dist/services/module.js +518 -0
- package/dist/services/module.js.map +1 -0
- package/dist/services/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +17 -5
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/utils/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/schema.prisma +101 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/models/marketplace.ts +16 -0
- package/src/models/module.ts +77 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/gradeWorksheet.ts +6 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/labChat.ts +5 -0
- package/src/routers/marketplace.ts +16 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/module.ts +118 -0
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- package/src/services/adminUser.ts +5 -2
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/assignment.ts +12 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/marketplace.ts +39 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +683 -0
- package/src/services/parentPortal.ts +85 -0
- package/src/services/school.ts +15 -3
- package/src/services/studentProgress.ts +135 -0
- package/src/utils/inference.ts +85 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- package/tests/unit/services/adminUser.test.ts +75 -0
- 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 }
|
package/src/services/school.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
{
|
|
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,
|
package/src/utils/inference.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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
|
-
.
|
|
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
|
+
});
|