@studious-lms/server 1.4.1 → 1.4.3

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 (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
@@ -0,0 +1,218 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { z } from 'zod';
3
+
4
+ const {
5
+ chatCreateMock,
6
+ responseParseMock,
7
+ modelsListMock,
8
+ prismaMocks,
9
+ logger,
10
+ } = vi.hoisted(() => ({
11
+ chatCreateMock: vi.fn(),
12
+ responseParseMock: vi.fn(),
13
+ modelsListMock: vi.fn(),
14
+ prismaMocks: {
15
+ aiUsageLog: {
16
+ create: vi.fn(),
17
+ },
18
+ message: {
19
+ create: vi.fn(),
20
+ },
21
+ },
22
+ logger: {
23
+ info: vi.fn(),
24
+ error: vi.fn(),
25
+ },
26
+ }));
27
+
28
+ vi.mock('openai', () => ({
29
+ default: class OpenAI {
30
+ chat = {
31
+ completions: {
32
+ create: chatCreateMock,
33
+ },
34
+ };
35
+
36
+ responses = {
37
+ parse: responseParseMock,
38
+ };
39
+
40
+ models = {
41
+ list: modelsListMock,
42
+ };
43
+ },
44
+ }));
45
+
46
+ vi.mock('openai/helpers/zod', () => ({
47
+ zodTextFormat: vi.fn((schema, name) => ({ schema, name })),
48
+ }));
49
+
50
+ vi.mock('../../src/lib/prisma.js', () => ({
51
+ prisma: prismaMocks,
52
+ }));
53
+
54
+ vi.mock('../../src/utils/logger.js', () => ({
55
+ logger,
56
+ }));
57
+
58
+ vi.mock('../../src/lib/pusher.js', () => ({
59
+ chatChannel: vi.fn(),
60
+ pusher: {
61
+ trigger: vi.fn(),
62
+ },
63
+ }));
64
+
65
+ vi.mock('../../src/utils/aiUser.js', () => ({
66
+ ensureAIUserExists: vi.fn(),
67
+ getAIUserId: vi.fn(() => 'AI_ASSISTANT'),
68
+ }));
69
+
70
+ vi.mock('../../src/lib/config/env.js', () => ({
71
+ env: {
72
+ INFERENCE_API_KEY: 'test-key',
73
+ INFERENCE_API_BASE_URL: 'http://localhost:11434/v1',
74
+ OPENAI_API_KEY: 'test-openai-key',
75
+ },
76
+ }));
77
+
78
+ import { estimateTokenCount, inference } from '../../src/utils/inference.js';
79
+
80
+ describe('inference', () => {
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ prismaMocks.aiUsageLog.create.mockResolvedValue({ id: 'log-1' });
84
+ });
85
+
86
+ test('logs chat completion usage with the provided reason', async () => {
87
+ chatCreateMock.mockResolvedValue({
88
+ choices: [{ message: { content: 'Hello back' } }],
89
+ usage: {
90
+ prompt_tokens: 12,
91
+ completion_tokens: 8,
92
+ total_tokens: 20,
93
+ },
94
+ });
95
+
96
+ const result = await inference<string>('Hello there', undefined, {
97
+ reason: 'student_progress_chat',
98
+ metadata: { classId: 'class-1', studentId: 'student-1' },
99
+ });
100
+
101
+ expect(result).toBe('Hello back');
102
+ expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
103
+ data: expect.objectContaining({
104
+ reason: 'student_progress_chat',
105
+ operation: 'chat.completions',
106
+ success: true,
107
+ promptTokens: 12,
108
+ completionTokens: 8,
109
+ totalTokens: 20,
110
+ estimatedInputTokens: estimateTokenCount('Hello there'),
111
+ metadata: { classId: 'class-1', studentId: 'student-1' },
112
+ }),
113
+ });
114
+ expect(logger.info).toHaveBeenCalledWith(
115
+ 'AI usage logged',
116
+ expect.objectContaining({
117
+ reason: 'student_progress_chat',
118
+ totalTokens: 20,
119
+ }),
120
+ );
121
+ expect(chatCreateMock).toHaveBeenCalledWith({
122
+ model: 'gpt-5-nano',
123
+ messages: [{ role: 'user', content: 'Hello there' }],
124
+ });
125
+ });
126
+
127
+ test('passes optional chat completion settings through the helper', async () => {
128
+ chatCreateMock.mockResolvedValue({
129
+ choices: [{ message: { content: 'Configured response' } }],
130
+ usage: {
131
+ prompt_tokens: 10,
132
+ completion_tokens: 5,
133
+ total_tokens: 15,
134
+ },
135
+ });
136
+
137
+ const result = await inference<string>('Introduce yourself', undefined, {
138
+ reason: 'newton_chat_introduction',
139
+ metadata: { conversationId: 'conversation-1' },
140
+ model: 'command-a-03-2025',
141
+ maxTokens: 300,
142
+ temperature: 0.8,
143
+ });
144
+
145
+ expect(result).toBe('Configured response');
146
+ expect(chatCreateMock).toHaveBeenCalledWith({
147
+ model: 'command-a-03-2025',
148
+ messages: [{ role: 'user', content: 'Introduce yourself' }],
149
+ max_tokens: 300,
150
+ temperature: 0.8,
151
+ });
152
+ expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
153
+ data: expect.objectContaining({
154
+ reason: 'newton_chat_introduction',
155
+ model: 'command-a-03-2025',
156
+ metadata: { conversationId: 'conversation-1' },
157
+ }),
158
+ });
159
+ });
160
+
161
+ test('logs structured response usage for parsed responses', async () => {
162
+ responseParseMock.mockResolvedValue({
163
+ output_parsed: { score: 4 },
164
+ usage: {
165
+ input_tokens: 30,
166
+ output_tokens: 10,
167
+ total_tokens: 40,
168
+ },
169
+ });
170
+
171
+ const schema = z.object({ score: z.number() });
172
+ const result = await inference<{ score: number }>('Grade this', schema, {
173
+ reason: 'worksheet_autograde',
174
+ metadata: { worksheetResponseId: 'response-1' },
175
+ });
176
+
177
+ expect(result).toEqual({ score: 4 });
178
+ expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
179
+ data: expect.objectContaining({
180
+ reason: 'worksheet_autograde',
181
+ operation: 'responses.parse',
182
+ success: true,
183
+ promptTokens: 30,
184
+ completionTokens: 10,
185
+ totalTokens: 40,
186
+ metadata: { worksheetResponseId: 'response-1' },
187
+ }),
188
+ });
189
+ });
190
+
191
+ test('logs failed attempts before rethrowing', async () => {
192
+ chatCreateMock.mockRejectedValue(new Error('OpenAI unavailable'));
193
+
194
+ await expect(
195
+ inference<string>('Hello there', undefined, {
196
+ reason: 'newton_chat_response',
197
+ metadata: { conversationId: 'conversation-1' },
198
+ }),
199
+ ).rejects.toThrow('OpenAI unavailable');
200
+
201
+ expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
202
+ data: expect.objectContaining({
203
+ reason: 'newton_chat_response',
204
+ operation: 'chat.completions',
205
+ success: false,
206
+ estimatedInputTokens: estimateTokenCount('Hello there'),
207
+ error: 'OpenAI unavailable',
208
+ metadata: { conversationId: 'conversation-1' },
209
+ }),
210
+ });
211
+ expect(logger.error).toHaveBeenCalledWith(
212
+ 'Failed to generate inference response',
213
+ expect.objectContaining({
214
+ reason: 'newton_chat_response',
215
+ }),
216
+ );
217
+ });
218
+ });
@@ -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
+ });
@@ -19,6 +19,7 @@ export default defineConfig({
19
19
  'tests/middleware/**/*.test.ts',
20
20
  'tests/lib/**/*.test.ts',
21
21
  'tests/pipelines/**/*.test.ts',
22
+ 'tests/routers/**/*.test.ts',
22
23
  ],
23
24
  env: {
24
25
  NODE_ENV: 'test',