@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
@@ -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
+ });