@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
@@ -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
+ });
@@ -0,0 +1,356 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { TRPCError } from "@trpc/server";
3
+
4
+ const { prismaMocks, pusherMocks, notificationMocks } = vi.hoisted(() => ({
5
+ prismaMocks: {
6
+ class: { findFirst: vi.fn(), findMany: vi.fn() },
7
+ discussion: {
8
+ findUnique: vi.fn(),
9
+ findUniqueOrThrow: vi.fn(),
10
+ findMany: vi.fn(),
11
+ create: vi.fn(),
12
+ update: vi.fn(),
13
+ delete: vi.fn(),
14
+ },
15
+ discussionReply: {
16
+ findUnique: vi.fn(),
17
+ findMany: vi.fn(),
18
+ create: vi.fn(),
19
+ update: vi.fn(),
20
+ updateMany: vi.fn(),
21
+ count: vi.fn(),
22
+ groupBy: vi.fn(),
23
+ },
24
+ discussionRead: {
25
+ findMany: vi.fn(),
26
+ upsert: vi.fn(),
27
+ },
28
+ reaction: {
29
+ findUnique: vi.fn(),
30
+ upsert: vi.fn(),
31
+ delete: vi.fn(),
32
+ },
33
+ file: { updateMany: vi.fn() },
34
+ user: { findMany: vi.fn() },
35
+ discussionTag: {
36
+ findMany: vi.fn(),
37
+ findUnique: vi.fn(),
38
+ create: vi.fn(),
39
+ update: vi.fn(),
40
+ delete: vi.fn(),
41
+ },
42
+ $transaction: vi.fn(),
43
+ },
44
+ pusherMocks: {
45
+ trigger: vi.fn(),
46
+ },
47
+ notificationMocks: {
48
+ sendNotification: vi.fn(),
49
+ },
50
+ }));
51
+
52
+ vi.mock("../../../src/lib/prisma.js", () => ({
53
+ prisma: prismaMocks,
54
+ }));
55
+
56
+ vi.mock("../../../src/lib/pusher.js", () => ({
57
+ pusher: pusherMocks,
58
+ discussionChannel: (classId: string) => `private-class-${classId}-discussions`,
59
+ }));
60
+
61
+ vi.mock("../../../src/services/notification.js", () => notificationMocks);
62
+
63
+ import {
64
+ buildReplyTree,
65
+ createDiscussion,
66
+ getDiscussion,
67
+ getUnreadCounts,
68
+ markAcceptedAnswer,
69
+ replyToDiscussion,
70
+ } from "../../../src/services/discussion.js";
71
+
72
+ const author = {
73
+ id: "author-1",
74
+ username: "teacher",
75
+ profile: {
76
+ displayName: "Teacher",
77
+ profilePicture: null,
78
+ profilePictureThumbnail: null,
79
+ },
80
+ };
81
+
82
+ function enabledTeacherClass(overrides = {}) {
83
+ return {
84
+ id: "class-1",
85
+ name: "History",
86
+ discussionsEnabled: true,
87
+ studentsCanCreateDiscussions: true,
88
+ studentsCanReply: true,
89
+ students: [],
90
+ teachers: [{ id: "teacher-1" }],
91
+ school: { memberships: [] },
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ function discussion(overrides = {}) {
97
+ return {
98
+ id: "discussion-1",
99
+ classId: "class-1",
100
+ authorId: "teacher-1",
101
+ title: "Question",
102
+ body: "Body",
103
+ type: "QUESTION",
104
+ isPinned: false,
105
+ isLocked: false,
106
+ allowReplies: true,
107
+ createdAt: new Date("2026-01-01T00:00:00Z"),
108
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
109
+ lastActivityAt: new Date("2026-01-01T00:00:00Z"),
110
+ author,
111
+ attachments: [],
112
+ tags: [],
113
+ reactions: [],
114
+ _count: { replies: 0 },
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function reply(overrides = {}) {
120
+ return {
121
+ id: "reply-1",
122
+ discussionId: "discussion-1",
123
+ authorId: "student-1",
124
+ parentId: null,
125
+ body: "Reply",
126
+ deletedAt: null,
127
+ isAcceptedAnswer: false,
128
+ createdAt: new Date("2026-01-01T00:00:00Z"),
129
+ modifiedAt: null,
130
+ author,
131
+ attachments: [],
132
+ reactions: [],
133
+ ...overrides,
134
+ };
135
+ }
136
+
137
+ beforeEach(() => {
138
+ vi.clearAllMocks();
139
+ prismaMocks.$transaction.mockImplementation((callback) => callback(prismaMocks));
140
+ pusherMocks.trigger.mockResolvedValue(undefined);
141
+ notificationMocks.sendNotification.mockResolvedValue({});
142
+ prismaMocks.user.findMany.mockResolvedValue([]);
143
+ });
144
+
145
+ describe("buildReplyTree", () => {
146
+ test("assembles nested replies and preserves soft-deleted parents", () => {
147
+ const tree = buildReplyTree(
148
+ [
149
+ reply({ id: "parent", body: "[deleted]", deletedAt: new Date("2026-01-02T00:00:00Z") }) as any,
150
+ reply({ id: "child", parentId: "parent", body: "Child" }) as any,
151
+ ],
152
+ "viewer-1"
153
+ );
154
+
155
+ expect(tree).toHaveLength(1);
156
+ expect(tree[0].body).toBe("[deleted]");
157
+ expect(tree[0].attachments).toEqual([]);
158
+ expect(tree[0].children[0].id).toBe("child");
159
+ });
160
+ });
161
+
162
+ describe("getDiscussion", () => {
163
+ test("marks the thread read and returns nested replies with permissions", async () => {
164
+ prismaMocks.discussion.findUnique.mockResolvedValue({
165
+ ...discussion(),
166
+ class: {
167
+ id: "class-1",
168
+ discussionsEnabled: true,
169
+ studentsCanReply: true,
170
+ teachers: [{ id: "teacher-1" }],
171
+ students: [],
172
+ school: { memberships: [] },
173
+ },
174
+ });
175
+ prismaMocks.discussion.findUniqueOrThrow.mockResolvedValue(discussion());
176
+ prismaMocks.discussionReply.findMany.mockResolvedValue([
177
+ reply({ id: "parent" }),
178
+ reply({ id: "child", parentId: "parent" }),
179
+ ]);
180
+ prismaMocks.reaction.findUnique.mockResolvedValue({ type: "HEART" });
181
+ prismaMocks.discussionRead.upsert.mockResolvedValue({});
182
+
183
+ const result = await getDiscussion("teacher-1", "discussion-1");
184
+
185
+ expect(prismaMocks.discussionRead.upsert).toHaveBeenCalledWith({
186
+ where: { userId_discussionId: { userId: "teacher-1", discussionId: "discussion-1" } },
187
+ update: { lastReadAt: expect.any(Date) },
188
+ create: { userId: "teacher-1", discussionId: "discussion-1" },
189
+ });
190
+ expect(result.replies[0].children[0].id).toBe("child");
191
+ expect(result.canPin).toBe(true);
192
+ expect(result.canReply).toBe(true);
193
+ expect(result.myReaction).toBe("HEART");
194
+ });
195
+ });
196
+
197
+ describe("getUnreadCounts", () => {
198
+ test("computes per-class totals from unread replies", async () => {
199
+ prismaMocks.class.findMany.mockResolvedValue([{ id: "class-1" }]);
200
+ prismaMocks.discussion.findMany.mockResolvedValue([
201
+ { id: "discussion-1" },
202
+ { id: "discussion-2" },
203
+ ]);
204
+ prismaMocks.discussionRead.findMany.mockResolvedValue([
205
+ { discussionId: "discussion-1", lastReadAt: new Date("2026-01-01T00:00:00Z") },
206
+ ]);
207
+ prismaMocks.discussionReply.groupBy
208
+ .mockResolvedValueOnce([{ discussionId: "discussion-2", _count: 1 }])
209
+ .mockResolvedValueOnce([{ discussionId: "discussion-1", _count: 2 }]);
210
+
211
+ const result = await getUnreadCounts("student-1", ["class-1"]);
212
+
213
+ expect(prismaMocks.discussionReply.groupBy).toHaveBeenNthCalledWith(1, {
214
+ by: ["discussionId"],
215
+ where: {
216
+ discussionId: { in: ["discussion-2"] },
217
+ deletedAt: null,
218
+ authorId: { not: "student-1" },
219
+ },
220
+ _count: true,
221
+ });
222
+ expect(prismaMocks.discussionReply.groupBy).toHaveBeenNthCalledWith(2, {
223
+ by: ["discussionId"],
224
+ where: {
225
+ deletedAt: null,
226
+ authorId: { not: "student-1" },
227
+ OR: [
228
+ {
229
+ discussionId: "discussion-1",
230
+ createdAt: { gt: new Date("2026-01-01T00:00:00Z") },
231
+ },
232
+ ],
233
+ },
234
+ _count: true,
235
+ });
236
+ expect(result["class-1"].total).toBe(3);
237
+ expect(result["class-1"].perDiscussion).toEqual([
238
+ { discussionId: "discussion-2", count: 1 },
239
+ { discussionId: "discussion-1", count: 2 },
240
+ ]);
241
+ });
242
+ });
243
+
244
+ describe("createDiscussion authorization", () => {
245
+ test("blocks student-created announcement discussions", async () => {
246
+ prismaMocks.class.findFirst.mockResolvedValue(
247
+ enabledTeacherClass({
248
+ teachers: [],
249
+ students: [{ id: "student-1" }],
250
+ })
251
+ );
252
+
253
+ await expect(
254
+ createDiscussion("student-1", {
255
+ classId: "class-1",
256
+ title: "Update",
257
+ body: "Read this",
258
+ type: "ANNOUNCEMENT",
259
+ })
260
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
261
+ });
262
+
263
+ test("blocks student-created discussions when disabled", async () => {
264
+ prismaMocks.class.findFirst.mockResolvedValue(
265
+ enabledTeacherClass({
266
+ teachers: [],
267
+ students: [{ id: "student-1" }],
268
+ studentsCanCreateDiscussions: false,
269
+ })
270
+ );
271
+
272
+ await expect(
273
+ createDiscussion("student-1", {
274
+ classId: "class-1",
275
+ title: "Question",
276
+ body: "Can I ask this?",
277
+ type: "DISCUSSION",
278
+ })
279
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
280
+ });
281
+ });
282
+
283
+ describe("replyToDiscussion authorization", () => {
284
+ test("blocks student replies on locked discussions", async () => {
285
+ prismaMocks.discussion.findUnique.mockResolvedValue({
286
+ ...discussion({ isLocked: true }),
287
+ class: {
288
+ id: "class-1",
289
+ discussionsEnabled: true,
290
+ studentsCanReply: true,
291
+ teachers: [],
292
+ students: [{ id: "student-1" }],
293
+ school: { memberships: [] },
294
+ },
295
+ });
296
+
297
+ await expect(
298
+ replyToDiscussion("student-1", {
299
+ discussionId: "discussion-1",
300
+ body: "Reply",
301
+ })
302
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
303
+ });
304
+ });
305
+
306
+ describe("markAcceptedAnswer", () => {
307
+ test("unmarks previous answers before accepting a reply", async () => {
308
+ prismaMocks.discussionReply.findUnique.mockResolvedValue({
309
+ ...reply({ id: "reply-1", authorId: "student-1" }),
310
+ discussion: discussion({ type: "QUESTION", authorId: "teacher-1" }),
311
+ });
312
+ prismaMocks.discussion.findUnique.mockResolvedValue({
313
+ ...discussion(),
314
+ class: {
315
+ id: "class-1",
316
+ discussionsEnabled: true,
317
+ studentsCanReply: true,
318
+ teachers: [{ id: "teacher-1" }],
319
+ students: [],
320
+ school: { memberships: [] },
321
+ },
322
+ });
323
+ prismaMocks.discussionReply.update.mockResolvedValue(
324
+ reply({ id: "reply-1", isAcceptedAnswer: true })
325
+ );
326
+
327
+ const result = await markAcceptedAnswer("teacher-1", "reply-1", true);
328
+
329
+ expect(prismaMocks.discussionReply.updateMany).toHaveBeenCalledWith({
330
+ where: { discussionId: "discussion-1", isAcceptedAnswer: true },
331
+ data: { isAcceptedAnswer: false },
332
+ });
333
+ expect(result.reply.isAcceptedAnswer).toBe(true);
334
+ expect(notificationMocks.sendNotification).toHaveBeenCalled();
335
+ });
336
+
337
+ test("rejects accepted answers on non-question discussions", async () => {
338
+ prismaMocks.discussionReply.findUnique.mockResolvedValue({
339
+ ...reply({ id: "reply-1" }),
340
+ discussion: discussion({ type: "DISCUSSION" }),
341
+ });
342
+ prismaMocks.discussion.findUnique.mockResolvedValue({
343
+ ...discussion(),
344
+ class: {
345
+ id: "class-1",
346
+ discussionsEnabled: true,
347
+ studentsCanReply: true,
348
+ teachers: [{ id: "teacher-1" }],
349
+ students: [],
350
+ school: { memberships: [] },
351
+ },
352
+ });
353
+
354
+ await expect(markAcceptedAnswer("teacher-1", "reply-1", true)).rejects.toBeInstanceOf(TRPCError);
355
+ });
356
+ });