@studious-lms/server 1.9.0 → 1.12.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 -0
- package/dist/index.js +40 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +7 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +8 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +5 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/pusher.d.ts +2 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +4 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +61 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +6 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +18 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +5 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +6 -0
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/event.d.ts +12 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +38 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +6 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +6 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +3 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +3 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +58 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +26 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +6400 -4078
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +6 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +190 -0
- package/dist/routers/admin.d.ts.map +1 -0
- package/dist/routers/admin.js +50 -0
- package/dist/routers/admin.js.map +1 -0
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +2 -0
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +50 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts +31 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +5 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +2 -0
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/discussion.d.ts +823 -0
- package/dist/routers/discussion.d.ts.map +1 -0
- package/dist/routers/discussion.js +151 -0
- package/dist/routers/discussion.js.map +1 -0
- package/dist/routers/event.d.ts +3 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +4 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/studentProgress.d.ts +45 -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/routers/worksheet.d.ts +3 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +1490 -1307
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +102 -0
- package/dist/services/adminInstitution.d.ts.map +1 -0
- package/dist/services/adminInstitution.js +117 -0
- package/dist/services/adminInstitution.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +45 -0
- package/dist/services/adminRunJob.d.ts.map +1 -0
- package/dist/services/adminRunJob.js +41 -0
- package/dist/services/adminRunJob.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +50 -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/class.d.ts +31 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +14 -2
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +2 -0
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/discussion.d.ts +762 -0
- package/dist/services/discussion.d.ts.map +1 -0
- package/dist/services/discussion.js +625 -0
- package/dist/services/discussion.js.map +1 -0
- package/dist/services/event.d.ts +3 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +8 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/studentProgress.d.ts +94 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +116 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/studentProgressInferenceCache.d.ts +16 -0
- package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
- package/dist/services/studentProgressInferenceCache.js +43 -0
- package/dist/services/studentProgressInferenceCache.js.map +1 -0
- package/dist/services/worksheet.d.ts +3 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +5 -5
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +12 -0
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +16 -3
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +8 -0
- package/dist/utils/adminPanelAccess.d.ts.map +1 -0
- package/dist/utils/adminPanelAccess.js +44 -0
- package/dist/utils/adminPanelAccess.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
- package/prisma/schema.prisma +97 -0
- package/src/index.ts +39 -2
- package/src/lib/config/cors.ts +5 -0
- package/src/lib/config/env.ts +3 -0
- package/src/lib/pusher.ts +2 -1
- package/src/models/class.ts +3 -0
- package/src/models/worksheet.ts +28 -0
- package/src/routers/_app.ts +4 -0
- package/src/routers/admin.ts +70 -0
- package/src/routers/class.ts +3 -0
- package/src/routers/discussion.ts +205 -0
- package/src/routers/studentProgress.ts +21 -0
- package/src/seedDatabase.ts +1713 -1361
- package/src/services/adminInstitution.ts +140 -0
- package/src/services/adminRunJob.ts +46 -0
- package/src/services/assignment.ts +11 -1
- package/src/services/class.ts +18 -0
- package/src/services/discussion.ts +701 -0
- package/src/services/studentProgress.ts +274 -11
- package/src/services/studentProgressInferenceCache.ts +64 -0
- package/src/services/worksheet.ts +4 -2
- package/src/trpc.ts +15 -0
- package/src/utils/adminPanelAccess.ts +46 -0
- package/tests/integration/routers/discussion.test.ts +253 -0
- package/tests/unit/services/discussion.test.ts +356 -0
- package/tests/unit/services/studentProgress.test.ts +168 -2
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { TRPCError } from "@trpc/server";
|
|
5
5
|
import type { AssignmentType } from "@prisma/client";
|
|
6
|
+
import { z } from "zod";
|
|
6
7
|
import { prisma } from "../lib/prisma.js";
|
|
7
8
|
import { isTeacherInClass } from "../models/class.js";
|
|
8
9
|
import { inference } from "../utils/inference.js";
|
|
9
10
|
import { logger } from "../utils/logger.js";
|
|
11
|
+
import {
|
|
12
|
+
getStudentProgressInferenceCacheHit,
|
|
13
|
+
setStudentProgressInferenceCache,
|
|
14
|
+
} from "./studentProgressInferenceCache.js";
|
|
10
15
|
|
|
11
16
|
type ProgressChatMessage = {
|
|
12
17
|
role: "user" | "assistant";
|
|
@@ -38,10 +43,38 @@ type ProgressStudent = {
|
|
|
38
43
|
profile: { displayName: string | null } | null;
|
|
39
44
|
};
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const insightItemSchema = z.object({
|
|
47
|
+
label: z.string(),
|
|
48
|
+
evidence: z.string(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const studentProgressInsightsSchema = z.object({
|
|
52
|
+
summary: z.string(),
|
|
53
|
+
strengths: z.array(insightItemSchema).min(2).max(4),
|
|
54
|
+
growth: z.array(insightItemSchema).min(2).max(4),
|
|
55
|
+
talkingPoints: z.array(z.string()).min(3).max(5),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const studentProgressForecastSchema = z.object({
|
|
59
|
+
forecastLow: z.number().min(0).max(100),
|
|
60
|
+
forecastHigh: z.number().min(0).max(100),
|
|
61
|
+
risk: z.enum(["on_track", "watch", "at_risk"]),
|
|
62
|
+
reason: z.string(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const studentProgressFocusSchema = z.object({
|
|
66
|
+
focusItems: z.array(z.string()).min(3).max(5),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type StudentProgressInsights = z.infer<
|
|
70
|
+
typeof studentProgressInsightsSchema
|
|
71
|
+
>;
|
|
72
|
+
export type StudentProgressForecast = z.infer<
|
|
73
|
+
typeof studentProgressForecastSchema
|
|
74
|
+
>;
|
|
75
|
+
export type StudentProgressFocus = z.infer<typeof studentProgressFocusSchema>;
|
|
76
|
+
|
|
77
|
+
type RecommendationType = "missing" | "low_score" | "awaiting_feedback";
|
|
45
78
|
|
|
46
79
|
function calculatePercentage(submission: GradeSubmission) {
|
|
47
80
|
if (submission.gradeReceived == null || !submission.assignment.maxGrade) {
|
|
@@ -131,8 +164,7 @@ function buildSuggestedAssignment(
|
|
|
131
164
|
percentage != null
|
|
132
165
|
? `${displayName} scored ${percentage}% and needs targeted reinforcement on the underlying concepts.`
|
|
133
166
|
: `${displayName} needs targeted reinforcement on the underlying concepts.`,
|
|
134
|
-
awaiting_feedback:
|
|
135
|
-
`${displayName} has work in progress here, so a short follow-up can keep momentum once feedback lands.`,
|
|
167
|
+
awaiting_feedback: `${displayName} has work in progress here, so a short follow-up can keep momentum once feedback lands.`,
|
|
136
168
|
};
|
|
137
169
|
|
|
138
170
|
return {
|
|
@@ -429,9 +461,14 @@ export async function getStudentProgressRecommendations(
|
|
|
429
461
|
studentId,
|
|
430
462
|
);
|
|
431
463
|
|
|
432
|
-
const summary = await summarizeStudentRecommendationsWithCases(
|
|
433
|
-
|
|
434
|
-
|
|
464
|
+
const summary = await summarizeStudentRecommendationsWithCases(
|
|
465
|
+
student,
|
|
466
|
+
submissions,
|
|
467
|
+
);
|
|
468
|
+
const {
|
|
469
|
+
recommendationCandidates: _recommendationCandidates,
|
|
470
|
+
...summaryResponse
|
|
471
|
+
} = summary;
|
|
435
472
|
return {
|
|
436
473
|
student: summaryResponse.student,
|
|
437
474
|
summary: summaryResponse.summary,
|
|
@@ -498,7 +535,10 @@ export async function getClassProgressRecommendations(
|
|
|
498
535
|
),
|
|
499
536
|
)
|
|
500
537
|
)
|
|
501
|
-
.map(
|
|
538
|
+
.map(
|
|
539
|
+
({ recommendationCandidates: _recommendationCandidates, ...student }) =>
|
|
540
|
+
student,
|
|
541
|
+
)
|
|
502
542
|
.filter((student) => student.recommendations.length > 0)
|
|
503
543
|
.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
504
544
|
|
|
@@ -591,6 +631,225 @@ function buildProgressSummary(submissions: GradeSubmission[]) {
|
|
|
591
631
|
}));
|
|
592
632
|
}
|
|
593
633
|
|
|
634
|
+
function buildStructuredProgressContext(
|
|
635
|
+
classData: { id: string; name: string; subject: string | null },
|
|
636
|
+
student: ProgressStudent,
|
|
637
|
+
submissions: GradeSubmission[],
|
|
638
|
+
) {
|
|
639
|
+
const displayName = student.profile?.displayName ?? student.username;
|
|
640
|
+
const recommendationSummary = summarizeStudentRecommendations(
|
|
641
|
+
student,
|
|
642
|
+
submissions,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
class: classData,
|
|
647
|
+
student: { id: student.id, username: student.username, displayName },
|
|
648
|
+
progress: {
|
|
649
|
+
overallGrade: getOverallGrade(submissions),
|
|
650
|
+
trend: calculateTrend(submissions),
|
|
651
|
+
summary: recommendationSummary.summary,
|
|
652
|
+
assignments: buildProgressSummary(submissions),
|
|
653
|
+
recommendations: recommendationSummary.recommendations.map(
|
|
654
|
+
(recommendation) => ({
|
|
655
|
+
title: recommendation.title,
|
|
656
|
+
recommendationType: recommendation.recommendationType,
|
|
657
|
+
percentage: recommendation.percentage,
|
|
658
|
+
submitted: recommendation.submitted,
|
|
659
|
+
reasons: recommendation.reasons,
|
|
660
|
+
sectionName: recommendation.sectionName,
|
|
661
|
+
}),
|
|
662
|
+
),
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function buildStructuredInferenceMessages(
|
|
668
|
+
instruction: string,
|
|
669
|
+
context: ReturnType<typeof buildStructuredProgressContext>,
|
|
670
|
+
) {
|
|
671
|
+
return [
|
|
672
|
+
{
|
|
673
|
+
role: "system" as const,
|
|
674
|
+
content:
|
|
675
|
+
"You are an educational progress analyst. Use only the provided class and grade context. Be specific, concise, supportive, and do not fabricate assignments, grades, or sections.",
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
role: "user" as const,
|
|
679
|
+
content: JSON.stringify(context),
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
role: "user" as const,
|
|
683
|
+
content: instruction,
|
|
684
|
+
},
|
|
685
|
+
];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
type ProgressInferenceCacheRow<T> = {
|
|
689
|
+
epoch: number;
|
|
690
|
+
cachedAt: number;
|
|
691
|
+
value: T;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const studentProgressInsightsCache = new Map<
|
|
695
|
+
string,
|
|
696
|
+
ProgressInferenceCacheRow<StudentProgressInsights>
|
|
697
|
+
>();
|
|
698
|
+
const studentProgressForecastCache = new Map<
|
|
699
|
+
string,
|
|
700
|
+
ProgressInferenceCacheRow<StudentProgressForecast>
|
|
701
|
+
>();
|
|
702
|
+
const studentProgressFocusCache = new Map<
|
|
703
|
+
string,
|
|
704
|
+
ProgressInferenceCacheRow<StudentProgressFocus>
|
|
705
|
+
>();
|
|
706
|
+
|
|
707
|
+
export async function getStudentProgressInsights(
|
|
708
|
+
viewerId: string,
|
|
709
|
+
classId: string,
|
|
710
|
+
studentId: string,
|
|
711
|
+
) {
|
|
712
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
713
|
+
studentProgressInsightsCache,
|
|
714
|
+
classId,
|
|
715
|
+
studentId,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
719
|
+
viewerId,
|
|
720
|
+
classId,
|
|
721
|
+
studentId,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
if (cached !== undefined) {
|
|
725
|
+
return cached;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const context = buildStructuredProgressContext(
|
|
729
|
+
classData,
|
|
730
|
+
student,
|
|
731
|
+
submissions,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const value = await inference<StudentProgressInsights>(
|
|
735
|
+
buildStructuredInferenceMessages(
|
|
736
|
+
[
|
|
737
|
+
"Generate an AI analytics summary for this one student's dashboard.",
|
|
738
|
+
"The summary must be 2-4 plain-prose sentences: lead with overall grade and trajectory, then call out one strength and one concern with assignment titles.",
|
|
739
|
+
"Strengths and growth items need 2-6 word labels and one-line evidence grounded in assignment titles or sections.",
|
|
740
|
+
"Talking points must be 3-5 specific teacher check-in prompts with no numbering.",
|
|
741
|
+
].join(" "),
|
|
742
|
+
context,
|
|
743
|
+
),
|
|
744
|
+
studentProgressInsightsSchema,
|
|
745
|
+
{
|
|
746
|
+
reason: "student_progress_insights",
|
|
747
|
+
metadata: { viewerId, classId, studentId },
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
setStudentProgressInferenceCache(
|
|
751
|
+
studentProgressInsightsCache,
|
|
752
|
+
classId,
|
|
753
|
+
studentId,
|
|
754
|
+
value,
|
|
755
|
+
);
|
|
756
|
+
return value;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export async function getStudentProgressForecast(
|
|
760
|
+
viewerId: string,
|
|
761
|
+
classId: string,
|
|
762
|
+
studentId: string,
|
|
763
|
+
) {
|
|
764
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
765
|
+
studentProgressForecastCache,
|
|
766
|
+
classId,
|
|
767
|
+
studentId,
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
771
|
+
viewerId,
|
|
772
|
+
classId,
|
|
773
|
+
studentId,
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
if (cached !== undefined) {
|
|
777
|
+
return cached;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const context = buildStructuredProgressContext(
|
|
781
|
+
classData,
|
|
782
|
+
student,
|
|
783
|
+
submissions,
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const value = await inference<StudentProgressForecast>(
|
|
787
|
+
buildStructuredInferenceMessages(
|
|
788
|
+
"Predict the student's next graded assignment percentage band and risk status. Base the forecast on trajectory, missing work, low scores, and assignment recency. Keep the reason to one sentence.",
|
|
789
|
+
context,
|
|
790
|
+
),
|
|
791
|
+
studentProgressForecastSchema,
|
|
792
|
+
{
|
|
793
|
+
reason: "student_progress_forecast",
|
|
794
|
+
metadata: { viewerId, classId, studentId },
|
|
795
|
+
},
|
|
796
|
+
);
|
|
797
|
+
setStudentProgressInferenceCache(
|
|
798
|
+
studentProgressForecastCache,
|
|
799
|
+
classId,
|
|
800
|
+
studentId,
|
|
801
|
+
value,
|
|
802
|
+
);
|
|
803
|
+
return value;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export async function getStudentProgressFocus(
|
|
807
|
+
viewerId: string,
|
|
808
|
+
classId: string,
|
|
809
|
+
studentId: string,
|
|
810
|
+
) {
|
|
811
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
812
|
+
studentProgressFocusCache,
|
|
813
|
+
classId,
|
|
814
|
+
studentId,
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
818
|
+
viewerId,
|
|
819
|
+
classId,
|
|
820
|
+
studentId,
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
if (cached !== undefined) {
|
|
824
|
+
return cached;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const context = buildStructuredProgressContext(
|
|
828
|
+
classData,
|
|
829
|
+
student,
|
|
830
|
+
submissions,
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const value = await inference<StudentProgressFocus>(
|
|
834
|
+
buildStructuredInferenceMessages(
|
|
835
|
+
"Generate 3-5 student-facing weekly focus items. Each item should be specific, actionable, and grounded in the student's grade pattern or assignment titles. No numbering in the strings.",
|
|
836
|
+
context,
|
|
837
|
+
),
|
|
838
|
+
studentProgressFocusSchema,
|
|
839
|
+
{
|
|
840
|
+
reason: "student_progress_focus",
|
|
841
|
+
metadata: { viewerId, classId, studentId },
|
|
842
|
+
},
|
|
843
|
+
);
|
|
844
|
+
setStudentProgressInferenceCache(
|
|
845
|
+
studentProgressFocusCache,
|
|
846
|
+
classId,
|
|
847
|
+
studentId,
|
|
848
|
+
value,
|
|
849
|
+
);
|
|
850
|
+
return value;
|
|
851
|
+
}
|
|
852
|
+
|
|
594
853
|
export async function chatAboutStudentProgress(
|
|
595
854
|
viewerId: string,
|
|
596
855
|
input: {
|
|
@@ -637,7 +896,11 @@ export async function chatAboutStudentProgress(
|
|
|
637
896
|
try {
|
|
638
897
|
const response = await inference<string>(messages, undefined, {
|
|
639
898
|
reason: "student_progress_chat",
|
|
640
|
-
metadata: {
|
|
899
|
+
metadata: {
|
|
900
|
+
viewerId,
|
|
901
|
+
classId: input.classId,
|
|
902
|
+
studentId: input.studentId,
|
|
903
|
+
},
|
|
641
904
|
});
|
|
642
905
|
if (typeof response !== "string" || response.trim().length === 0) {
|
|
643
906
|
throw new Error("Student progress chat returned an empty response");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process cache for student progress AI panels (insights / forecast / focus).
|
|
3
|
+
* Epoch bumps invalidate all three for a given class + student (e.g. after a grade update).
|
|
4
|
+
* Entries also expire after one week so stale AI summaries eventually refresh.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const epochByScope = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
function scopeKey(classId: string, studentId: string) {
|
|
12
|
+
return `${classId}:${studentId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function currentStudentProgressInferenceEpoch(
|
|
16
|
+
classId: string,
|
|
17
|
+
studentId: string,
|
|
18
|
+
) {
|
|
19
|
+
return epochByScope.get(scopeKey(classId, studentId)) ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Call when a teacher updates `gradeReceived` for that student's work in this class. */
|
|
23
|
+
export function invalidateStudentProgressInferenceCache(
|
|
24
|
+
classId: string,
|
|
25
|
+
studentId: string,
|
|
26
|
+
) {
|
|
27
|
+
const key = scopeKey(classId, studentId);
|
|
28
|
+
epochByScope.set(key, (epochByScope.get(key) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type CacheRow<T> = { epoch: number; cachedAt: number; value: T };
|
|
32
|
+
|
|
33
|
+
export function getStudentProgressInferenceCacheHit<T>(
|
|
34
|
+
store: Map<string, CacheRow<T>>,
|
|
35
|
+
classId: string,
|
|
36
|
+
studentId: string,
|
|
37
|
+
): T | undefined {
|
|
38
|
+
const key = scopeKey(classId, studentId);
|
|
39
|
+
const epoch = currentStudentProgressInferenceEpoch(classId, studentId);
|
|
40
|
+
const hit = store.get(key);
|
|
41
|
+
if (!hit) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const fresh =
|
|
45
|
+
hit.epoch === epoch && Date.now() - hit.cachedAt < WEEK_MS;
|
|
46
|
+
if (!fresh) {
|
|
47
|
+
store.delete(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return hit.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setStudentProgressInferenceCache<T>(
|
|
54
|
+
store: Map<string, CacheRow<T>>,
|
|
55
|
+
classId: string,
|
|
56
|
+
studentId: string,
|
|
57
|
+
value: T,
|
|
58
|
+
) {
|
|
59
|
+
store.set(scopeKey(classId, studentId), {
|
|
60
|
+
epoch: currentStudentProgressInferenceEpoch(classId, studentId),
|
|
61
|
+
cachedAt: Date.now(),
|
|
62
|
+
value,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
createStudentQuestionProgressForGrading,
|
|
36
36
|
createComment,
|
|
37
37
|
findStudentQuestionProgressByIdForTeacher,
|
|
38
|
+
findStudentQuestionProgressByIdForStudentOrTeacher,
|
|
38
39
|
findStudentQuestionProgressByIdForTeacherAndResponse,
|
|
39
40
|
} from "../models/worksheet.js";
|
|
40
41
|
|
|
@@ -645,14 +646,15 @@ export async function addCommentToResponse(
|
|
|
645
646
|
comment: string,
|
|
646
647
|
authorId: string
|
|
647
648
|
) {
|
|
648
|
-
const progress = await
|
|
649
|
+
const progress = await findStudentQuestionProgressByIdForStudentOrTeacher(
|
|
649
650
|
responseId,
|
|
650
651
|
authorId
|
|
651
652
|
);
|
|
652
653
|
if (!progress) {
|
|
653
654
|
throw new TRPCError({
|
|
654
655
|
code: "FORBIDDEN",
|
|
655
|
-
message:
|
|
656
|
+
message:
|
|
657
|
+
"You can only comment on your own worksheet answers or on answers for classes you teach",
|
|
656
658
|
});
|
|
657
659
|
}
|
|
658
660
|
|
package/src/trpc.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Request, Response } from 'express';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';
|
|
9
9
|
import { findUserBySessionToken } from './models/auth.js';
|
|
10
|
+
import { assertAdminPanelUser } from './utils/adminPanelAccess.js';
|
|
10
11
|
|
|
11
12
|
interface CreateContextOptions {
|
|
12
13
|
req: Request;
|
|
@@ -88,6 +89,20 @@ export const publicProcedure = t.procedure.use(loggingMiddleware);
|
|
|
88
89
|
// Protected procedures
|
|
89
90
|
export const protectedProcedure = publicProcedure.use(isAuthed);
|
|
90
91
|
|
|
92
|
+
const adminPanelMiddleware = t.middleware(({ ctx, next }) => {
|
|
93
|
+
if (!ctx.user) {
|
|
94
|
+
throw new TRPCError({
|
|
95
|
+
code: 'UNAUTHORIZED',
|
|
96
|
+
message: 'Not authenticated',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
assertAdminPanelUser(ctx.user.id);
|
|
100
|
+
return next({ ctx });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Authenticated + env allowlist (see `assertAdminPanelUser`). */
|
|
104
|
+
export const adminProcedure = protectedProcedure.use(adminPanelMiddleware);
|
|
105
|
+
|
|
91
106
|
export const protectedClassMemberProcedure = protectedProcedure
|
|
92
107
|
.input(z.object({ classId: z.string() }).passthrough())
|
|
93
108
|
.use(isMemberInClass);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { env } from '../lib/config/env.js';
|
|
3
|
+
|
|
4
|
+
function parseAdminUserIds(raw: string | undefined): Set<string> {
|
|
5
|
+
if (!raw?.trim()) return new Set();
|
|
6
|
+
return new Set(
|
|
7
|
+
raw
|
|
8
|
+
.split(/[\s,]+/)
|
|
9
|
+
.map((s) => s.trim())
|
|
10
|
+
.filter(Boolean),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty
|
|
16
|
+
* comma/whitespace-separated list of user UUIDs. In non-production, an empty
|
|
17
|
+
* unset value allows any authenticated user (local ops); a non-empty list is
|
|
18
|
+
* still enforced.
|
|
19
|
+
*/
|
|
20
|
+
export function assertAdminPanelUser(userId: string): void {
|
|
21
|
+
const allowed = parseAdminUserIds(env.ADMIN_PANEL_USER_IDS);
|
|
22
|
+
|
|
23
|
+
if (env.NODE_ENV === 'production') {
|
|
24
|
+
if (allowed.size === 0) {
|
|
25
|
+
throw new TRPCError({
|
|
26
|
+
code: 'FORBIDDEN',
|
|
27
|
+
message:
|
|
28
|
+
'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (!allowed.has(userId)) {
|
|
32
|
+
throw new TRPCError({
|
|
33
|
+
code: 'FORBIDDEN',
|
|
34
|
+
message: 'You are not allowed to use the admin API.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (allowed.size > 0 && !allowed.has(userId)) {
|
|
41
|
+
throw new TRPCError({
|
|
42
|
+
code: 'FORBIDDEN',
|
|
43
|
+
message: 'You are not allowed to use the admin API.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|