@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.
Files changed (163) hide show
  1. package/.env.example +3 -0
  2. package/dist/index.js +40 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/config/cors.d.ts.map +1 -1
  5. package/dist/lib/config/cors.js +7 -2
  6. package/dist/lib/config/cors.js.map +1 -1
  7. package/dist/lib/config/env.d.ts +8 -0
  8. package/dist/lib/config/env.d.ts.map +1 -1
  9. package/dist/lib/config/env.js +5 -2
  10. package/dist/lib/config/env.js.map +1 -1
  11. package/dist/lib/pusher.d.ts +2 -1
  12. package/dist/lib/pusher.d.ts.map +1 -1
  13. package/dist/lib/pusher.js +4 -3
  14. package/dist/lib/pusher.js.map +1 -1
  15. package/dist/models/agenda.d.ts +6 -0
  16. package/dist/models/agenda.d.ts.map +1 -1
  17. package/dist/models/announcement.d.ts +3 -0
  18. package/dist/models/announcement.d.ts.map +1 -1
  19. package/dist/models/assignment.d.ts +61 -0
  20. package/dist/models/assignment.d.ts.map +1 -1
  21. package/dist/models/auth.d.ts +6 -0
  22. package/dist/models/auth.d.ts.map +1 -1
  23. package/dist/models/class.d.ts +18 -0
  24. package/dist/models/class.d.ts.map +1 -1
  25. package/dist/models/class.js +5 -2
  26. package/dist/models/class.js.map +1 -1
  27. package/dist/models/comment.d.ts +6 -0
  28. package/dist/models/comment.d.ts.map +1 -1
  29. package/dist/models/event.d.ts +12 -0
  30. package/dist/models/event.d.ts.map +1 -1
  31. package/dist/models/file.d.ts +38 -0
  32. package/dist/models/file.d.ts.map +1 -1
  33. package/dist/models/folder.d.ts +6 -0
  34. package/dist/models/folder.d.ts.map +1 -1
  35. package/dist/models/labChat.d.ts +6 -0
  36. package/dist/models/labChat.d.ts.map +1 -1
  37. package/dist/models/newtonChat.d.ts +3 -0
  38. package/dist/models/newtonChat.d.ts.map +1 -1
  39. package/dist/models/school.d.ts +3 -0
  40. package/dist/models/school.d.ts.map +1 -1
  41. package/dist/models/section.d.ts +3 -0
  42. package/dist/models/section.d.ts.map +1 -1
  43. package/dist/models/worksheet.d.ts +58 -0
  44. package/dist/models/worksheet.d.ts.map +1 -1
  45. package/dist/models/worksheet.js +26 -2
  46. package/dist/models/worksheet.js.map +1 -1
  47. package/dist/routers/_app.d.ts +6400 -4078
  48. package/dist/routers/_app.d.ts.map +1 -1
  49. package/dist/routers/_app.js +6 -2
  50. package/dist/routers/_app.js.map +1 -1
  51. package/dist/routers/admin.d.ts +190 -0
  52. package/dist/routers/admin.d.ts.map +1 -0
  53. package/dist/routers/admin.js +50 -0
  54. package/dist/routers/admin.js.map +1 -0
  55. package/dist/routers/agenda.d.ts +6 -0
  56. package/dist/routers/agenda.d.ts.map +1 -1
  57. package/dist/routers/announcement.d.ts +2 -0
  58. package/dist/routers/announcement.d.ts.map +1 -1
  59. package/dist/routers/assignment.d.ts +50 -0
  60. package/dist/routers/assignment.d.ts.map +1 -1
  61. package/dist/routers/class.d.ts +31 -0
  62. package/dist/routers/class.d.ts.map +1 -1
  63. package/dist/routers/class.js +5 -2
  64. package/dist/routers/class.js.map +1 -1
  65. package/dist/routers/comment.d.ts +2 -0
  66. package/dist/routers/comment.d.ts.map +1 -1
  67. package/dist/routers/discussion.d.ts +823 -0
  68. package/dist/routers/discussion.d.ts.map +1 -0
  69. package/dist/routers/discussion.js +151 -0
  70. package/dist/routers/discussion.js.map +1 -0
  71. package/dist/routers/event.d.ts +3 -0
  72. package/dist/routers/event.d.ts.map +1 -1
  73. package/dist/routers/file.d.ts +4 -0
  74. package/dist/routers/file.d.ts.map +1 -1
  75. package/dist/routers/school.d.ts +6 -0
  76. package/dist/routers/school.d.ts.map +1 -1
  77. package/dist/routers/studentProgress.d.ts +45 -0
  78. package/dist/routers/studentProgress.d.ts.map +1 -1
  79. package/dist/routers/studentProgress.js +12 -3
  80. package/dist/routers/studentProgress.js.map +1 -1
  81. package/dist/routers/worksheet.d.ts +3 -0
  82. package/dist/routers/worksheet.d.ts.map +1 -1
  83. package/dist/seedDatabase.d.ts.map +1 -1
  84. package/dist/seedDatabase.js +1490 -1307
  85. package/dist/seedDatabase.js.map +1 -1
  86. package/dist/services/adminInstitution.d.ts +102 -0
  87. package/dist/services/adminInstitution.d.ts.map +1 -0
  88. package/dist/services/adminInstitution.js +117 -0
  89. package/dist/services/adminInstitution.js.map +1 -0
  90. package/dist/services/adminRunJob.d.ts +45 -0
  91. package/dist/services/adminRunJob.d.ts.map +1 -0
  92. package/dist/services/adminRunJob.js +41 -0
  93. package/dist/services/adminRunJob.js.map +1 -0
  94. package/dist/services/agenda.d.ts +6 -0
  95. package/dist/services/agenda.d.ts.map +1 -1
  96. package/dist/services/assignment.d.ts +50 -0
  97. package/dist/services/assignment.d.ts.map +1 -1
  98. package/dist/services/assignment.js +8 -3
  99. package/dist/services/assignment.js.map +1 -1
  100. package/dist/services/class.d.ts +31 -0
  101. package/dist/services/class.d.ts.map +1 -1
  102. package/dist/services/class.js +14 -2
  103. package/dist/services/class.js.map +1 -1
  104. package/dist/services/comment.d.ts +2 -0
  105. package/dist/services/comment.d.ts.map +1 -1
  106. package/dist/services/discussion.d.ts +762 -0
  107. package/dist/services/discussion.d.ts.map +1 -0
  108. package/dist/services/discussion.js +625 -0
  109. package/dist/services/discussion.js.map +1 -0
  110. package/dist/services/event.d.ts +3 -0
  111. package/dist/services/event.d.ts.map +1 -1
  112. package/dist/services/file.d.ts +8 -0
  113. package/dist/services/file.d.ts.map +1 -1
  114. package/dist/services/school.d.ts +6 -0
  115. package/dist/services/school.d.ts.map +1 -1
  116. package/dist/services/studentProgress.d.ts +94 -0
  117. package/dist/services/studentProgress.d.ts.map +1 -1
  118. package/dist/services/studentProgress.js +116 -3
  119. package/dist/services/studentProgress.js.map +1 -1
  120. package/dist/services/studentProgressInferenceCache.d.ts +16 -0
  121. package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
  122. package/dist/services/studentProgressInferenceCache.js +43 -0
  123. package/dist/services/studentProgressInferenceCache.js.map +1 -0
  124. package/dist/services/worksheet.d.ts +3 -0
  125. package/dist/services/worksheet.d.ts.map +1 -1
  126. package/dist/services/worksheet.js +5 -5
  127. package/dist/services/worksheet.js.map +1 -1
  128. package/dist/trpc.d.ts +12 -0
  129. package/dist/trpc.d.ts.map +1 -1
  130. package/dist/trpc.js +16 -3
  131. package/dist/trpc.js.map +1 -1
  132. package/dist/utils/adminPanelAccess.d.ts +8 -0
  133. package/dist/utils/adminPanelAccess.d.ts.map +1 -0
  134. package/dist/utils/adminPanelAccess.js +44 -0
  135. package/dist/utils/adminPanelAccess.js.map +1 -0
  136. package/package.json +1 -1
  137. package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
  138. package/prisma/schema.prisma +97 -0
  139. package/src/index.ts +39 -2
  140. package/src/lib/config/cors.ts +5 -0
  141. package/src/lib/config/env.ts +3 -0
  142. package/src/lib/pusher.ts +2 -1
  143. package/src/models/class.ts +3 -0
  144. package/src/models/worksheet.ts +28 -0
  145. package/src/routers/_app.ts +4 -0
  146. package/src/routers/admin.ts +70 -0
  147. package/src/routers/class.ts +3 -0
  148. package/src/routers/discussion.ts +205 -0
  149. package/src/routers/studentProgress.ts +21 -0
  150. package/src/seedDatabase.ts +1713 -1361
  151. package/src/services/adminInstitution.ts +140 -0
  152. package/src/services/adminRunJob.ts +46 -0
  153. package/src/services/assignment.ts +11 -1
  154. package/src/services/class.ts +18 -0
  155. package/src/services/discussion.ts +701 -0
  156. package/src/services/studentProgress.ts +274 -11
  157. package/src/services/studentProgressInferenceCache.ts +64 -0
  158. package/src/services/worksheet.ts +4 -2
  159. package/src/trpc.ts +15 -0
  160. package/src/utils/adminPanelAccess.ts +46 -0
  161. package/tests/integration/routers/discussion.test.ts +253 -0
  162. package/tests/unit/services/discussion.test.ts +356 -0
  163. 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
- type RecommendationType =
42
- | "missing"
43
- | "low_score"
44
- | "awaiting_feedback";
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(student, submissions);
433
- const { recommendationCandidates: _recommendationCandidates, ...summaryResponse } =
434
- summary;
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(({ recommendationCandidates: _recommendationCandidates, ...student }) => student)
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: { viewerId, classId: input.classId, studentId: input.studentId },
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 findStudentQuestionProgressByIdForTeacher(
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: "Only class teachers can comment on worksheet responses",
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
+ }