@studious-lms/server 1.11.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 (156) 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 +4055 -1823
  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/worksheet.d.ts +3 -0
  78. package/dist/routers/worksheet.d.ts.map +1 -1
  79. package/dist/seedDatabase.d.ts.map +1 -1
  80. package/dist/seedDatabase.js +1490 -1307
  81. package/dist/seedDatabase.js.map +1 -1
  82. package/dist/services/adminInstitution.d.ts +102 -0
  83. package/dist/services/adminInstitution.d.ts.map +1 -0
  84. package/dist/services/adminInstitution.js +117 -0
  85. package/dist/services/adminInstitution.js.map +1 -0
  86. package/dist/services/adminRunJob.d.ts +45 -0
  87. package/dist/services/adminRunJob.d.ts.map +1 -0
  88. package/dist/services/adminRunJob.js +41 -0
  89. package/dist/services/adminRunJob.js.map +1 -0
  90. package/dist/services/agenda.d.ts +6 -0
  91. package/dist/services/agenda.d.ts.map +1 -1
  92. package/dist/services/assignment.d.ts +50 -0
  93. package/dist/services/assignment.d.ts.map +1 -1
  94. package/dist/services/assignment.js +8 -3
  95. package/dist/services/assignment.js.map +1 -1
  96. package/dist/services/class.d.ts +31 -0
  97. package/dist/services/class.d.ts.map +1 -1
  98. package/dist/services/class.js +14 -2
  99. package/dist/services/class.js.map +1 -1
  100. package/dist/services/comment.d.ts +2 -0
  101. package/dist/services/comment.d.ts.map +1 -1
  102. package/dist/services/discussion.d.ts +762 -0
  103. package/dist/services/discussion.d.ts.map +1 -0
  104. package/dist/services/discussion.js +625 -0
  105. package/dist/services/discussion.js.map +1 -0
  106. package/dist/services/event.d.ts +3 -0
  107. package/dist/services/event.d.ts.map +1 -1
  108. package/dist/services/file.d.ts +8 -0
  109. package/dist/services/file.d.ts.map +1 -1
  110. package/dist/services/school.d.ts +6 -0
  111. package/dist/services/school.d.ts.map +1 -1
  112. package/dist/services/studentProgress.d.ts.map +1 -1
  113. package/dist/services/studentProgress.js +27 -5
  114. package/dist/services/studentProgress.js.map +1 -1
  115. package/dist/services/studentProgressInferenceCache.d.ts +16 -0
  116. package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
  117. package/dist/services/studentProgressInferenceCache.js +43 -0
  118. package/dist/services/studentProgressInferenceCache.js.map +1 -0
  119. package/dist/services/worksheet.d.ts +3 -0
  120. package/dist/services/worksheet.d.ts.map +1 -1
  121. package/dist/services/worksheet.js +5 -5
  122. package/dist/services/worksheet.js.map +1 -1
  123. package/dist/trpc.d.ts +12 -0
  124. package/dist/trpc.d.ts.map +1 -1
  125. package/dist/trpc.js +16 -3
  126. package/dist/trpc.js.map +1 -1
  127. package/dist/utils/adminPanelAccess.d.ts +8 -0
  128. package/dist/utils/adminPanelAccess.d.ts.map +1 -0
  129. package/dist/utils/adminPanelAccess.js +44 -0
  130. package/dist/utils/adminPanelAccess.js.map +1 -0
  131. package/package.json +1 -1
  132. package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
  133. package/prisma/schema.prisma +97 -0
  134. package/src/index.ts +39 -2
  135. package/src/lib/config/cors.ts +5 -0
  136. package/src/lib/config/env.ts +3 -0
  137. package/src/lib/pusher.ts +2 -1
  138. package/src/models/class.ts +3 -0
  139. package/src/models/worksheet.ts +28 -0
  140. package/src/routers/_app.ts +4 -0
  141. package/src/routers/admin.ts +70 -0
  142. package/src/routers/class.ts +3 -0
  143. package/src/routers/discussion.ts +205 -0
  144. package/src/seedDatabase.ts +1713 -1361
  145. package/src/services/adminInstitution.ts +140 -0
  146. package/src/services/adminRunJob.ts +46 -0
  147. package/src/services/assignment.ts +11 -1
  148. package/src/services/class.ts +18 -0
  149. package/src/services/discussion.ts +701 -0
  150. package/src/services/studentProgress.ts +80 -3
  151. package/src/services/studentProgressInferenceCache.ts +64 -0
  152. package/src/services/worksheet.ts +4 -2
  153. package/src/trpc.ts +15 -0
  154. package/src/utils/adminPanelAccess.ts +46 -0
  155. package/tests/integration/routers/discussion.test.ts +253 -0
  156. package/tests/unit/services/discussion.test.ts +356 -0
@@ -8,6 +8,10 @@ import { prisma } from "../lib/prisma.js";
8
8
  import { isTeacherInClass } from "../models/class.js";
9
9
  import { inference } from "../utils/inference.js";
10
10
  import { logger } from "../utils/logger.js";
11
+ import {
12
+ getStudentProgressInferenceCacheHit,
13
+ setStudentProgressInferenceCache,
14
+ } from "./studentProgressInferenceCache.js";
11
15
 
12
16
  type ProgressChatMessage = {
13
17
  role: "user" | "assistant";
@@ -681,23 +685,53 @@ function buildStructuredInferenceMessages(
681
685
  ];
682
686
  }
683
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
+
684
707
  export async function getStudentProgressInsights(
685
708
  viewerId: string,
686
709
  classId: string,
687
710
  studentId: string,
688
711
  ) {
712
+ const cached = getStudentProgressInferenceCacheHit(
713
+ studentProgressInsightsCache,
714
+ classId,
715
+ studentId,
716
+ );
717
+
689
718
  const { classData, student, submissions } = await loadStudentProgressContext(
690
719
  viewerId,
691
720
  classId,
692
721
  studentId,
693
722
  );
723
+
724
+ if (cached !== undefined) {
725
+ return cached;
726
+ }
727
+
694
728
  const context = buildStructuredProgressContext(
695
729
  classData,
696
730
  student,
697
731
  submissions,
698
732
  );
699
733
 
700
- return inference<StudentProgressInsights>(
734
+ const value = await inference<StudentProgressInsights>(
701
735
  buildStructuredInferenceMessages(
702
736
  [
703
737
  "Generate an AI analytics summary for this one student's dashboard.",
@@ -713,6 +747,13 @@ export async function getStudentProgressInsights(
713
747
  metadata: { viewerId, classId, studentId },
714
748
  },
715
749
  );
750
+ setStudentProgressInferenceCache(
751
+ studentProgressInsightsCache,
752
+ classId,
753
+ studentId,
754
+ value,
755
+ );
756
+ return value;
716
757
  }
717
758
 
718
759
  export async function getStudentProgressForecast(
@@ -720,18 +761,29 @@ export async function getStudentProgressForecast(
720
761
  classId: string,
721
762
  studentId: string,
722
763
  ) {
764
+ const cached = getStudentProgressInferenceCacheHit(
765
+ studentProgressForecastCache,
766
+ classId,
767
+ studentId,
768
+ );
769
+
723
770
  const { classData, student, submissions } = await loadStudentProgressContext(
724
771
  viewerId,
725
772
  classId,
726
773
  studentId,
727
774
  );
775
+
776
+ if (cached !== undefined) {
777
+ return cached;
778
+ }
779
+
728
780
  const context = buildStructuredProgressContext(
729
781
  classData,
730
782
  student,
731
783
  submissions,
732
784
  );
733
785
 
734
- return inference<StudentProgressForecast>(
786
+ const value = await inference<StudentProgressForecast>(
735
787
  buildStructuredInferenceMessages(
736
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.",
737
789
  context,
@@ -742,6 +794,13 @@ export async function getStudentProgressForecast(
742
794
  metadata: { viewerId, classId, studentId },
743
795
  },
744
796
  );
797
+ setStudentProgressInferenceCache(
798
+ studentProgressForecastCache,
799
+ classId,
800
+ studentId,
801
+ value,
802
+ );
803
+ return value;
745
804
  }
746
805
 
747
806
  export async function getStudentProgressFocus(
@@ -749,18 +808,29 @@ export async function getStudentProgressFocus(
749
808
  classId: string,
750
809
  studentId: string,
751
810
  ) {
811
+ const cached = getStudentProgressInferenceCacheHit(
812
+ studentProgressFocusCache,
813
+ classId,
814
+ studentId,
815
+ );
816
+
752
817
  const { classData, student, submissions } = await loadStudentProgressContext(
753
818
  viewerId,
754
819
  classId,
755
820
  studentId,
756
821
  );
822
+
823
+ if (cached !== undefined) {
824
+ return cached;
825
+ }
826
+
757
827
  const context = buildStructuredProgressContext(
758
828
  classData,
759
829
  student,
760
830
  submissions,
761
831
  );
762
832
 
763
- return inference<StudentProgressFocus>(
833
+ const value = await inference<StudentProgressFocus>(
764
834
  buildStructuredInferenceMessages(
765
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.",
766
836
  context,
@@ -771,6 +841,13 @@ export async function getStudentProgressFocus(
771
841
  metadata: { viewerId, classId, studentId },
772
842
  },
773
843
  );
844
+ setStudentProgressInferenceCache(
845
+ studentProgressFocusCache,
846
+ classId,
847
+ studentId,
848
+ value,
849
+ );
850
+ return value;
774
851
  }
775
852
 
776
853
  export async function chatAboutStudentProgress(
@@ -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
+ }
@@ -0,0 +1,253 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { expectForbidden, expectUnauth } from '../helpers';
3
+ import { user1Caller, user2Caller, user3Caller } from '../setup';
4
+
5
+ vi.mock('../../../src/lib/pusher.js', async () => ({
6
+ pusher: { trigger: vi.fn().mockResolvedValue(undefined) },
7
+ chatChannel: (conversationId: string) => `private-conversation-${conversationId}`,
8
+ worksheetChannel: (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`,
9
+ teacherChannel: (classId: string) => `private-teacher-${classId}`,
10
+ discussionChannel: (classId: string) => `private-class-${classId}-discussions`,
11
+ worksheetJobChannel: (worksheetId: string) => `private-worksheet-job-${worksheetId}`,
12
+ }));
13
+
14
+ describe('Discussion Router', () => {
15
+ let testClass: any;
16
+ let studentId: string;
17
+ let secondStudentId: string;
18
+
19
+ beforeEach(async () => {
20
+ testClass = await user1Caller.class.create({
21
+ name: `Discussion Test Class ${Date.now()}`,
22
+ subject: 'History',
23
+ section: '12th Grade',
24
+ });
25
+
26
+ const studentProfile = await user2Caller.user.getProfile();
27
+ const secondStudentProfile = await user3Caller.user.getProfile();
28
+ studentId = studentProfile.id;
29
+ secondStudentId = secondStudentProfile.id;
30
+
31
+ await user1Caller.class.addStudent({
32
+ classId: testClass.id,
33
+ studentId,
34
+ });
35
+ await user1Caller.class.addStudent({
36
+ classId: testClass.id,
37
+ studentId: secondStudentId,
38
+ });
39
+ });
40
+
41
+ test('creates, lists, searches, paginates, tags, and reacts to discussions', async () => {
42
+ const tag = await user1Caller.discussion.tags.create({
43
+ classId: testClass.id,
44
+ name: `Review ${Date.now()}`,
45
+ color: '#3B82F6',
46
+ });
47
+
48
+ const first = await user1Caller.discussion.create({
49
+ classId: testClass.id,
50
+ title: 'Primary source analysis',
51
+ body: 'Discuss the archive packet.',
52
+ type: 'DISCUSSION',
53
+ tagIds: [tag.id],
54
+ });
55
+ const question = await user1Caller.discussion.create({
56
+ classId: testClass.id,
57
+ title: 'Exam review question',
58
+ body: 'Where should we focus?',
59
+ type: 'QUESTION',
60
+ });
61
+ await user1Caller.discussion.create({
62
+ classId: testClass.id,
63
+ title: 'Reading logistics',
64
+ body: 'Bring the printed handout.',
65
+ type: 'ANNOUNCEMENT',
66
+ });
67
+
68
+ await user1Caller.discussion.pin({
69
+ discussionId: first.discussion.id,
70
+ isPinned: true,
71
+ });
72
+
73
+ await user2Caller.discussion.reply({
74
+ discussionId: first.discussion.id,
75
+ body: 'I found one strong quote.',
76
+ });
77
+ await user2Caller.discussion.reply({
78
+ discussionId: first.discussion.id,
79
+ body: 'A second point for ranking.',
80
+ });
81
+ await user2Caller.discussion.reply({
82
+ discussionId: question.discussion.id,
83
+ body: 'Focus on the essay question.',
84
+ });
85
+
86
+ const list = await user2Caller.discussion.list({
87
+ classId: testClass.id,
88
+ sort: 'top',
89
+ limit: 2,
90
+ });
91
+ expect(list.items).toHaveLength(2);
92
+ expect(list.nextCursor).toBeDefined();
93
+ expect(list.items[0].id).toBe(first.discussion.id);
94
+ expect(list.items[0].isPinned).toBe(true);
95
+ expect(list.items[0].replyCount).toBe(2);
96
+
97
+ const search = await user2Caller.discussion.list({
98
+ classId: testClass.id,
99
+ search: 'exam review',
100
+ sort: 'activity',
101
+ limit: 10,
102
+ });
103
+ expect(search.items.map((item: any) => item.id)).toContain(question.discussion.id);
104
+
105
+ const byTag = await user2Caller.discussion.list({
106
+ classId: testClass.id,
107
+ tagId: tag.id,
108
+ sort: 'activity',
109
+ limit: 10,
110
+ });
111
+ expect(byTag.items.map((item: any) => item.id)).toEqual([first.discussion.id]);
112
+
113
+ const reaction = await user2Caller.discussion.reactions.toggle({
114
+ discussionId: first.discussion.id,
115
+ type: 'HEART',
116
+ });
117
+ expect(reaction.reaction?.type).toBe('HEART');
118
+
119
+ const removedReaction = await user2Caller.discussion.reactions.toggle({
120
+ discussionId: first.discussion.id,
121
+ type: 'HEART',
122
+ });
123
+ expect(removedReaction.reaction).toBeNull();
124
+ });
125
+
126
+ test('returns nested replies, preserves children after soft delete, and toggles accepted answers', async () => {
127
+ const created = await user1Caller.discussion.create({
128
+ classId: testClass.id,
129
+ title: 'Question thread',
130
+ body: 'Which answer is best?',
131
+ type: 'QUESTION',
132
+ });
133
+
134
+ const parent = await user2Caller.discussion.reply({
135
+ discussionId: created.discussion.id,
136
+ body: 'This is my answer.',
137
+ });
138
+ const child = await user3Caller.discussion.reply({
139
+ discussionId: created.discussion.id,
140
+ parentReplyId: parent.reply.id,
141
+ body: 'Adding evidence under that answer.',
142
+ });
143
+
144
+ const accepted = await user1Caller.discussion.markAcceptedAnswer({
145
+ replyId: parent.reply.id,
146
+ accepted: true,
147
+ });
148
+ expect(accepted.reply.isAcceptedAnswer).toBe(true);
149
+
150
+ const deleted = await user2Caller.discussion.deleteReply({
151
+ replyId: parent.reply.id,
152
+ });
153
+ expect(deleted.reply.body).toBe('[deleted]');
154
+ expect(deleted.reply.deletedAt).toBeTruthy();
155
+
156
+ const thread = await user1Caller.discussion.get({
157
+ discussionId: created.discussion.id,
158
+ });
159
+ expect(thread.replies).toHaveLength(1);
160
+ expect(thread.replies[0].id).toBe(parent.reply.id);
161
+ expect(thread.replies[0].body).toBe('[deleted]');
162
+ expect(thread.replies[0].children[0].id).toBe(child.reply.id);
163
+ });
164
+
165
+ test('computes unread counts after a reader marks a discussion read', async () => {
166
+ const created = await user1Caller.discussion.create({
167
+ classId: testClass.id,
168
+ title: 'Unread count thread',
169
+ body: 'Watch unread counts here.',
170
+ type: 'DISCUSSION',
171
+ });
172
+
173
+ await user2Caller.discussion.get({ discussionId: created.discussion.id });
174
+ await user3Caller.discussion.reply({
175
+ discussionId: created.discussion.id,
176
+ body: 'This should be unread for the first student.',
177
+ });
178
+
179
+ const counts = await user2Caller.discussion.getUnreadCounts({
180
+ classIds: [testClass.id],
181
+ });
182
+ expect(counts[testClass.id].total).toBe(1);
183
+ expect(counts[testClass.id].perDiscussion).toEqual([
184
+ { discussionId: created.discussion.id, count: 1 },
185
+ ]);
186
+
187
+ await user2Caller.discussion.markRead({ discussionId: created.discussion.id });
188
+ const afterRead = await user2Caller.discussion.getUnreadCounts({
189
+ classIds: [testClass.id],
190
+ });
191
+ expect(afterRead[testClass.id].total).toBe(0);
192
+ });
193
+
194
+ test('enforces discussion authorization branches', async () => {
195
+ await expectUnauth((caller) => caller.discussion.list({
196
+ classId: testClass.id,
197
+ sort: 'activity',
198
+ limit: 10,
199
+ }));
200
+
201
+ await expectForbidden(() => user2Caller.discussion.create({
202
+ classId: testClass.id,
203
+ title: 'Student announcement',
204
+ body: 'Students cannot make this type.',
205
+ type: 'ANNOUNCEMENT',
206
+ }));
207
+
208
+ await user1Caller.class.update({
209
+ classId: testClass.id,
210
+ studentsCanCreateDiscussions: false,
211
+ });
212
+ await expectForbidden(() => user2Caller.discussion.create({
213
+ classId: testClass.id,
214
+ title: 'Blocked student discussion',
215
+ body: 'This should not create.',
216
+ type: 'DISCUSSION',
217
+ }));
218
+
219
+ const created = await user1Caller.discussion.create({
220
+ classId: testClass.id,
221
+ title: 'Teacher managed thread',
222
+ body: 'Teacher owns this.',
223
+ type: 'DISCUSSION',
224
+ });
225
+
226
+ await expectForbidden(() => user2Caller.discussion.pin({
227
+ discussionId: created.discussion.id,
228
+ isPinned: true,
229
+ }));
230
+
231
+ await user1Caller.discussion.lock({
232
+ discussionId: created.discussion.id,
233
+ isLocked: true,
234
+ });
235
+ await expectForbidden(() => user2Caller.discussion.reply({
236
+ discussionId: created.discussion.id,
237
+ body: 'Student cannot reply to locked thread.',
238
+ }));
239
+
240
+ await user1Caller.class.update({
241
+ classId: testClass.id,
242
+ discussionsEnabled: false,
243
+ });
244
+ await expectForbidden(() => user1Caller.discussion.list({
245
+ classId: testClass.id,
246
+ sort: 'activity',
247
+ limit: 10,
248
+ }));
249
+ await expectForbidden(() => user1Caller.discussion.get({
250
+ discussionId: created.discussion.id,
251
+ }));
252
+ });
253
+ });