@studious-lms/server 1.4.0 → 1.4.2

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 (166) hide show
  1. package/.env.example +6 -0
  2. package/.env.test.example +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +36 -50
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config/cors.d.ts +16 -0
  7. package/dist/lib/config/cors.d.ts.map +1 -0
  8. package/dist/lib/config/cors.js +75 -0
  9. package/dist/lib/config/cors.js.map +1 -0
  10. package/dist/lib/config/env.d.ts +14 -0
  11. package/dist/lib/config/env.d.ts.map +1 -1
  12. package/dist/lib/config/env.js +9 -2
  13. package/dist/lib/config/env.js.map +1 -1
  14. package/dist/lib/prisma.d.ts +14 -2
  15. package/dist/lib/prisma.d.ts.map +1 -1
  16. package/dist/lib/prisma.js +27 -8
  17. package/dist/lib/prisma.js.map +1 -1
  18. package/dist/middleware/security.d.ts.map +1 -1
  19. package/dist/middleware/security.js +3 -3
  20. package/dist/middleware/security.js.map +1 -1
  21. package/dist/models/agenda.d.ts +16 -16
  22. package/dist/models/announcement.d.ts +59 -23
  23. package/dist/models/announcement.d.ts.map +1 -1
  24. package/dist/models/assignment.d.ts +363 -276
  25. package/dist/models/assignment.d.ts.map +1 -1
  26. package/dist/models/attendance.d.ts +63 -21
  27. package/dist/models/attendance.d.ts.map +1 -1
  28. package/dist/models/auth.d.ts +102 -18
  29. package/dist/models/auth.d.ts.map +1 -1
  30. package/dist/models/class.d.ts +112 -64
  31. package/dist/models/class.d.ts.map +1 -1
  32. package/dist/models/comment.d.ts +52 -16
  33. package/dist/models/comment.d.ts.map +1 -1
  34. package/dist/models/conversation.d.ts +46 -16
  35. package/dist/models/conversation.d.ts.map +1 -1
  36. package/dist/models/event.d.ts +107 -53
  37. package/dist/models/event.d.ts.map +1 -1
  38. package/dist/models/file.d.ts +213 -165
  39. package/dist/models/file.d.ts.map +1 -1
  40. package/dist/models/folder.d.ts +161 -77
  41. package/dist/models/folder.d.ts.map +1 -1
  42. package/dist/models/labChat.d.ts +73 -31
  43. package/dist/models/labChat.d.ts.map +1 -1
  44. package/dist/models/marketing.d.ts +25 -7
  45. package/dist/models/marketing.d.ts.map +1 -1
  46. package/dist/models/message.d.ts +31 -13
  47. package/dist/models/message.d.ts.map +1 -1
  48. package/dist/models/newtonChat.d.ts +34 -10
  49. package/dist/models/newtonChat.d.ts.map +1 -1
  50. package/dist/models/notification.d.ts +25 -7
  51. package/dist/models/notification.d.ts.map +1 -1
  52. package/dist/models/section.d.ts +71 -23
  53. package/dist/models/section.d.ts.map +1 -1
  54. package/dist/models/user.d.ts +27 -9
  55. package/dist/models/user.d.ts.map +1 -1
  56. package/dist/models/worksheet.d.ts +237 -108
  57. package/dist/models/worksheet.d.ts.map +1 -1
  58. package/dist/pipelines/aiLabChat.d.ts +30 -6
  59. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  60. package/dist/pipelines/aiLabChat.js +157 -234
  61. package/dist/pipelines/aiLabChat.js.map +1 -1
  62. package/dist/pipelines/aiLabChatContract.d.ts +413 -0
  63. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -0
  64. package/dist/pipelines/aiLabChatContract.js +74 -0
  65. package/dist/pipelines/aiLabChatContract.js.map +1 -0
  66. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  67. package/dist/pipelines/gradeWorksheet.js +4 -4
  68. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  69. package/dist/pipelines/labChatPrompt.d.ts +29 -0
  70. package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
  71. package/dist/pipelines/labChatPrompt.js +146 -0
  72. package/dist/pipelines/labChatPrompt.js.map +1 -0
  73. package/dist/routers/_app.d.ts +1622 -1260
  74. package/dist/routers/_app.d.ts.map +1 -1
  75. package/dist/routers/_app.js +4 -2
  76. package/dist/routers/_app.js.map +1 -1
  77. package/dist/routers/agenda.d.ts +16 -16
  78. package/dist/routers/announcement.d.ts +19 -19
  79. package/dist/routers/assignment.d.ts +307 -291
  80. package/dist/routers/assignment.d.ts.map +1 -1
  81. package/dist/routers/assignment.js +3 -2
  82. package/dist/routers/assignment.js.map +1 -1
  83. package/dist/routers/attendance.d.ts +7 -7
  84. package/dist/routers/auth.d.ts +1 -1
  85. package/dist/routers/class.d.ts +77 -71
  86. package/dist/routers/class.d.ts.map +1 -1
  87. package/dist/routers/comment.d.ts +6 -6
  88. package/dist/routers/conversation.d.ts +11 -11
  89. package/dist/routers/event.d.ts +35 -35
  90. package/dist/routers/file.d.ts +12 -12
  91. package/dist/routers/folder.d.ts +54 -54
  92. package/dist/routers/labChat.d.ts +12 -12
  93. package/dist/routers/marketing.d.ts +2 -2
  94. package/dist/routers/message.d.ts +2 -2
  95. package/dist/routers/newtonChat.d.ts +1 -1
  96. package/dist/routers/notifications.d.ts +4 -4
  97. package/dist/routers/section.d.ts +7 -7
  98. package/dist/routers/studentProgress.d.ts +161 -0
  99. package/dist/routers/studentProgress.d.ts.map +1 -0
  100. package/dist/routers/studentProgress.js +43 -0
  101. package/dist/routers/studentProgress.js.map +1 -0
  102. package/dist/routers/user.d.ts +1 -1
  103. package/dist/routers/worksheet.d.ts +58 -58
  104. package/dist/seedDatabase.d.ts +1 -1
  105. package/dist/services/agenda.d.ts +16 -16
  106. package/dist/services/announcement.d.ts +8 -8
  107. package/dist/services/assignment.d.ts +299 -283
  108. package/dist/services/assignment.d.ts.map +1 -1
  109. package/dist/services/assignment.js +24 -5
  110. package/dist/services/assignment.js.map +1 -1
  111. package/dist/services/attendance.d.ts +7 -7
  112. package/dist/services/auth.d.ts +1 -1
  113. package/dist/services/class.d.ts +73 -67
  114. package/dist/services/class.d.ts.map +1 -1
  115. package/dist/services/comment.d.ts +6 -6
  116. package/dist/services/conversation.d.ts +11 -11
  117. package/dist/services/event.d.ts +31 -31
  118. package/dist/services/file.d.ts +12 -12
  119. package/dist/services/folder.d.ts +52 -52
  120. package/dist/services/labChat.d.ts +12 -12
  121. package/dist/services/labChat.d.ts.map +1 -1
  122. package/dist/services/labChat.js +31 -15
  123. package/dist/services/labChat.js.map +1 -1
  124. package/dist/services/marketing.d.ts +2 -2
  125. package/dist/services/message.d.ts.map +1 -1
  126. package/dist/services/message.js +90 -48
  127. package/dist/services/message.js.map +1 -1
  128. package/dist/services/notification.d.ts +4 -4
  129. package/dist/services/section.d.ts +6 -6
  130. package/dist/services/studentProgress.d.ts +120 -0
  131. package/dist/services/studentProgress.d.ts.map +1 -0
  132. package/dist/services/studentProgress.js +481 -0
  133. package/dist/services/studentProgress.js.map +1 -0
  134. package/dist/services/worksheet.d.ts +49 -49
  135. package/dist/utils/inference.d.ts +0 -11
  136. package/dist/utils/inference.d.ts.map +1 -1
  137. package/dist/utils/inference.js +2 -50
  138. package/dist/utils/inference.js.map +1 -1
  139. package/package.json +2 -2
  140. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  141. package/prisma/schema.prisma +14 -0
  142. package/sentry.properties +3 -0
  143. package/src/index.ts +39 -51
  144. package/src/lib/config/cors.ts +96 -0
  145. package/src/lib/config/env.ts +12 -1
  146. package/src/lib/prisma.ts +25 -6
  147. package/src/middleware/security.ts +1 -1
  148. package/src/pipelines/aiLabChat.ts +206 -246
  149. package/src/pipelines/aiLabChatContract.ts +75 -0
  150. package/src/pipelines/gradeWorksheet.ts +2 -2
  151. package/src/pipelines/labChatPrompt.ts +196 -0
  152. package/src/routers/_app.ts +4 -2
  153. package/src/routers/assignment.ts +1 -0
  154. package/src/routers/studentProgress.ts +71 -0
  155. package/src/services/assignment.ts +30 -2
  156. package/src/services/labChat.ts +31 -22
  157. package/src/services/message.ts +97 -48
  158. package/src/services/studentProgress.ts +691 -0
  159. package/src/utils/inference.ts +0 -61
  160. package/tests/lib/aiLabChatContract.test.ts +32 -0
  161. package/tests/lib/cors.test.ts +103 -0
  162. package/tests/pipelines/aiLabChat.test.ts +75 -0
  163. package/tests/routers/studentProgress.test.ts +254 -0
  164. package/tests/utils/aiLabChatPrompt.test.ts +126 -0
  165. package/tests/utils/studentProgress.test.ts +361 -0
  166. package/vitest.unit.config.ts +8 -1
@@ -0,0 +1,361 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ const { prismaMocks, isTeacherInClass, inference, logger } = vi.hoisted(() => ({
4
+ prismaMocks: {
5
+ class: { findUnique: vi.fn() },
6
+ user: { findFirst: vi.fn() },
7
+ submission: { findMany: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
8
+ },
9
+ isTeacherInClass: vi.fn(),
10
+ inference: vi.fn(),
11
+ logger: { error: vi.fn() },
12
+ }));
13
+
14
+ vi.mock("../../src/lib/prisma.js", () => ({
15
+ prisma: prismaMocks,
16
+ }));
17
+
18
+ vi.mock("../../src/models/class.js", () => ({
19
+ isTeacherInClass,
20
+ }));
21
+
22
+ vi.mock("../../src/utils/inference.js", () => ({
23
+ inference,
24
+ }));
25
+
26
+ vi.mock("../../src/utils/logger.js", () => ({
27
+ logger,
28
+ }));
29
+
30
+ import {
31
+ chatAboutStudentProgress,
32
+ dismissStudentRecommendation,
33
+ getClassProgressRecommendations,
34
+ getStudentProgressRecommendations,
35
+ } from "../../src/services/studentProgress.js";
36
+
37
+ function createSubmission(overrides: Partial<any> = {}) {
38
+ return {
39
+ id: overrides.id ?? "submission-1",
40
+ gradeReceived: overrides.gradeReceived ?? null,
41
+ submitted: overrides.submitted ?? false,
42
+ returned: overrides.returned ?? false,
43
+ submittedAt: overrides.submittedAt ?? null,
44
+ recommendationState: overrides.recommendationState ?? "NONE",
45
+ targetedAssignmentId: overrides.targetedAssignmentId ?? null,
46
+ assignment: {
47
+ id: overrides.assignment?.id ?? "assignment-1",
48
+ title: overrides.assignment?.title ?? "Assignment",
49
+ dueDate: overrides.assignment?.dueDate ?? daysFromNow(-2),
50
+ maxGrade: overrides.assignment?.maxGrade ?? 100,
51
+ weight: overrides.assignment?.weight ?? 1,
52
+ type: overrides.assignment?.type ?? "HOMEWORK",
53
+ section: overrides.assignment?.section ?? null,
54
+ },
55
+ };
56
+ }
57
+
58
+ function daysFromNow(days: number) {
59
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
60
+ }
61
+
62
+ describe("studentProgress service", () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ prismaMocks.submission.update.mockResolvedValue({});
66
+ prismaMocks.submission.findFirst.mockResolvedValue({ id: "submission-1" });
67
+ });
68
+
69
+ test("returns prioritized student recommendations with normalized assignment types", async () => {
70
+ isTeacherInClass.mockResolvedValue(false);
71
+ prismaMocks.class.findUnique.mockResolvedValue({
72
+ id: "class-1",
73
+ name: "Algebra",
74
+ subject: "Math",
75
+ });
76
+ prismaMocks.user.findFirst.mockResolvedValue({
77
+ id: "student-1",
78
+ username: "student1",
79
+ profile: { displayName: "Student One" },
80
+ });
81
+ prismaMocks.submission.findMany.mockResolvedValue([
82
+ createSubmission({
83
+ id: "missing-submission",
84
+ assignment: {
85
+ id: "missing-assignment",
86
+ title: "Missing Quiz",
87
+ dueDate: daysFromNow(-2),
88
+ type: "QUIZ",
89
+ section: { id: "section-1", name: "Fractions" },
90
+ },
91
+ }),
92
+ createSubmission({
93
+ id: "low-score-submission",
94
+ gradeReceived: 60,
95
+ submitted: true,
96
+ submittedAt: daysFromNow(-4),
97
+ assignment: {
98
+ id: "low-score-assignment",
99
+ title: "Fractions Test",
100
+ dueDate: daysFromNow(-3),
101
+ type: "TEST",
102
+ },
103
+ }),
104
+ ]);
105
+
106
+ const result = await getStudentProgressRecommendations(
107
+ "student-1",
108
+ "class-1",
109
+ "student-1",
110
+ );
111
+
112
+ expect(result.student).toMatchObject({
113
+ id: "student-1",
114
+ displayName: "Student One",
115
+ });
116
+ expect(result.summary).toMatchObject({
117
+ overallGrade: 60,
118
+ completedAssignments: 1,
119
+ totalAssignments: 2,
120
+ missingCount: 1,
121
+ lowScoreCount: 1,
122
+ });
123
+ expect(result.recommendations).toHaveLength(2);
124
+ expect(result.recommendations[0]).toMatchObject({
125
+ assignmentId: "missing-assignment",
126
+ recommendationType: "missing",
127
+ });
128
+ expect(result.recommendations[0]?.suggestedAssignment).toMatchObject({
129
+ title: "Make-up: Missing Quiz",
130
+ type: "HOMEWORK",
131
+ sectionId: "section-1",
132
+ sourceAssignmentId: "missing-assignment",
133
+ });
134
+ expect(result.recommendations[1]).toMatchObject({
135
+ assignmentId: "low-score-assignment",
136
+ recommendationType: "low_score",
137
+ percentage: 60,
138
+ });
139
+ expect(result.recommendations[1]?.suggestedAssignment?.type).toBe(
140
+ "HOMEWORK",
141
+ );
142
+ expect(result.nextSteps).toContain("Prioritize 1 missing assignment.");
143
+ expect(result.nextSteps).toContain(
144
+ "Review 1 low-scoring assignment before introducing new material.",
145
+ );
146
+ });
147
+
148
+ test("aggregates class-wide recommendation counts for teachers", async () => {
149
+ isTeacherInClass.mockResolvedValue(true);
150
+ prismaMocks.class.findUnique.mockResolvedValue({
151
+ id: "class-1",
152
+ name: "Algebra",
153
+ subject: "Math",
154
+ students: [
155
+ {
156
+ id: "student-1",
157
+ username: "student1",
158
+ profile: { displayName: "Student One" },
159
+ submissions: [
160
+ createSubmission({
161
+ id: "student-1-missing",
162
+ assignment: {
163
+ id: "missing-assignment",
164
+ title: "Missed Homework",
165
+ dueDate: daysFromNow(-2),
166
+ type: "HOMEWORK",
167
+ },
168
+ }),
169
+ createSubmission({
170
+ id: "student-1-low",
171
+ gradeReceived: 62,
172
+ submitted: true,
173
+ submittedAt: daysFromNow(-4),
174
+ assignment: {
175
+ id: "student-1-low-assignment",
176
+ title: "Student 1 Test",
177
+ dueDate: daysFromNow(-3),
178
+ type: "TEST",
179
+ },
180
+ }),
181
+ ],
182
+ },
183
+ {
184
+ id: "student-2",
185
+ username: "student2",
186
+ profile: { displayName: "Student Two" },
187
+ submissions: [
188
+ createSubmission({
189
+ id: "student-2-low",
190
+ gradeReceived: 40,
191
+ submitted: true,
192
+ submittedAt: daysFromNow(-3),
193
+ assignment: {
194
+ id: "student-2-low-assignment",
195
+ title: "Student 2 Quiz",
196
+ dueDate: daysFromNow(-3),
197
+ type: "QUIZ",
198
+ },
199
+ }),
200
+ ],
201
+ },
202
+ {
203
+ id: "student-3",
204
+ username: "student3",
205
+ profile: { displayName: "Student Three" },
206
+ submissions: [
207
+ createSubmission({
208
+ id: "student-3-ok",
209
+ gradeReceived: 95,
210
+ submitted: true,
211
+ submittedAt: daysFromNow(-3),
212
+ assignment: {
213
+ id: "student-3-ok-assignment",
214
+ title: "Completed Work",
215
+ dueDate: daysFromNow(-3),
216
+ type: "HOMEWORK",
217
+ },
218
+ }),
219
+ ],
220
+ },
221
+ ],
222
+ });
223
+
224
+ const result = await getClassProgressRecommendations("teacher-1", "class-1");
225
+
226
+ expect(result.class).toMatchObject({
227
+ id: "class-1",
228
+ name: "Algebra",
229
+ subject: "Math",
230
+ });
231
+ expect(result.summary).toMatchObject({
232
+ studentsWithConcerns: 2,
233
+ totalRecommendations: 3,
234
+ totalMissingAssignments: 1,
235
+ totalLowScoreAssignments: 2,
236
+ });
237
+ expect(result.students).toHaveLength(2);
238
+ expect(result.students[0]?.student.id).toBe("student-1");
239
+ expect(result.students[0]?.recommendations[0]).toMatchObject({
240
+ assignmentId: "missing-assignment",
241
+ recommendationType: "missing",
242
+ });
243
+ expect(result.students[1]?.student.id).toBe("student-2");
244
+ });
245
+
246
+ test("rejects class-wide recommendations for non-teachers", async () => {
247
+ isTeacherInClass.mockResolvedValue(false);
248
+
249
+ await expect(
250
+ getClassProgressRecommendations("student-1", "class-1"),
251
+ ).rejects.toMatchObject({
252
+ code: "UNAUTHORIZED",
253
+ });
254
+ });
255
+
256
+ test("falls back to a deterministic chat summary when inference fails", async () => {
257
+ isTeacherInClass.mockResolvedValue(false);
258
+ inference.mockRejectedValue(new Error("upstream failure"));
259
+ prismaMocks.class.findUnique.mockResolvedValue({
260
+ id: "class-1",
261
+ name: "Algebra",
262
+ subject: "Math",
263
+ });
264
+ prismaMocks.user.findFirst.mockResolvedValue({
265
+ id: "student-1",
266
+ username: "student1",
267
+ profile: { displayName: "Student One" },
268
+ });
269
+ prismaMocks.submission.findMany.mockResolvedValue([
270
+ createSubmission({
271
+ id: "missing-submission",
272
+ assignment: {
273
+ id: "missing-assignment",
274
+ title: "Missing Quiz",
275
+ dueDate: daysFromNow(-2),
276
+ type: "QUIZ",
277
+ },
278
+ }),
279
+ createSubmission({
280
+ id: "low-score-submission",
281
+ gradeReceived: 60,
282
+ submitted: true,
283
+ submittedAt: daysFromNow(-4),
284
+ assignment: {
285
+ id: "low-score-assignment",
286
+ title: "Fractions Test",
287
+ dueDate: daysFromNow(-3),
288
+ type: "TEST",
289
+ },
290
+ }),
291
+ ]);
292
+
293
+ const result = await chatAboutStudentProgress("student-1", {
294
+ classId: "class-1",
295
+ studentId: "student-1",
296
+ message: "How is this student doing?",
297
+ });
298
+
299
+ expect(result.isFallback).toBe(true);
300
+ expect(result.message).toContain("Student One's current overall grade is 60%.");
301
+ expect(result.message).toContain("1 assignment appears overdue.");
302
+ expect(result.message).toContain("1 graded assignment is below 70%.");
303
+ expect(logger.error).toHaveBeenCalled();
304
+ });
305
+
306
+ test("suppresses recommendations for submissions marked ASSIGNED", async () => {
307
+ isTeacherInClass.mockResolvedValue(false);
308
+ prismaMocks.class.findUnique.mockResolvedValue({
309
+ id: "class-1",
310
+ name: "Algebra",
311
+ subject: "Math",
312
+ });
313
+ prismaMocks.user.findFirst.mockResolvedValue({
314
+ id: "student-1",
315
+ username: "student1",
316
+ profile: { displayName: "Student One" },
317
+ });
318
+ prismaMocks.submission.findMany.mockResolvedValue([
319
+ createSubmission({
320
+ id: "low-score-submission",
321
+ gradeReceived: 52,
322
+ submitted: true,
323
+ submittedAt: daysFromNow(-4),
324
+ recommendationState: "ASSIGNED",
325
+ assignment: {
326
+ id: "low-score-assignment",
327
+ title: "Fractions Test",
328
+ dueDate: daysFromNow(-3),
329
+ type: "TEST",
330
+ },
331
+ }),
332
+ ]);
333
+
334
+ const result = await getStudentProgressRecommendations(
335
+ "student-1",
336
+ "class-1",
337
+ "student-1",
338
+ );
339
+
340
+ expect(result.recommendations).toHaveLength(0);
341
+ });
342
+
343
+ test("allows teachers to dismiss a recommendation", async () => {
344
+ isTeacherInClass.mockResolvedValue(true);
345
+ prismaMocks.submission.findFirst.mockResolvedValue({ id: "submission-1" });
346
+
347
+ const result = await dismissStudentRecommendation("teacher-1", {
348
+ classId: "class-1",
349
+ studentId: "student-1",
350
+ submissionId: "submission-1",
351
+ });
352
+
353
+ expect(result).toEqual({ success: true });
354
+ expect(prismaMocks.submission.update).toHaveBeenCalledWith({
355
+ where: { id: "submission-1" },
356
+ data: expect.objectContaining({
357
+ recommendationState: "DISMISSED",
358
+ }),
359
+ });
360
+ });
361
+ });
@@ -13,7 +13,14 @@ export default defineConfig({
13
13
  test: {
14
14
  globals: true,
15
15
  environment: 'node',
16
- include: ['tests/utils/**/*.test.ts', 'tests/server/**/*.test.ts', 'tests/middleware/**/*.test.ts', 'tests/lib/**/*.test.ts'],
16
+ include: [
17
+ 'tests/utils/**/*.test.ts',
18
+ 'tests/server/**/*.test.ts',
19
+ 'tests/middleware/**/*.test.ts',
20
+ 'tests/lib/**/*.test.ts',
21
+ 'tests/pipelines/**/*.test.ts',
22
+ 'tests/routers/**/*.test.ts',
23
+ ],
17
24
  env: {
18
25
  NODE_ENV: 'test',
19
26
  },