@studious-lms/server 1.25.0 → 1.27.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 (175) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/models/cloudStorage.d.ts +11 -0
  5. package/dist/models/cloudStorage.d.ts.map +1 -1
  6. package/dist/models/cloudStorage.js +18 -2
  7. package/dist/models/cloudStorage.js.map +1 -1
  8. package/dist/models/conversation.d.ts +1 -0
  9. package/dist/models/conversation.d.ts.map +1 -1
  10. package/dist/models/labChat.d.ts +40 -0
  11. package/dist/models/labChat.d.ts.map +1 -1
  12. package/dist/models/labChat.js +32 -2
  13. package/dist/models/labChat.js.map +1 -1
  14. package/dist/models/marketplace.d.ts +35 -0
  15. package/dist/models/marketplace.d.ts.map +1 -1
  16. package/dist/models/marketplace.js +9 -2
  17. package/dist/models/marketplace.js.map +1 -1
  18. package/dist/models/message.d.ts +3 -0
  19. package/dist/models/message.d.ts.map +1 -1
  20. package/dist/models/module.d.ts +228 -0
  21. package/dist/models/module.d.ts.map +1 -0
  22. package/dist/models/module.js +69 -0
  23. package/dist/models/module.js.map +1 -0
  24. package/dist/pipelines/aiLabChat.d.ts +2 -0
  25. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  26. package/dist/pipelines/aiLabChat.js +60 -16
  27. package/dist/pipelines/aiLabChat.js.map +1 -1
  28. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  29. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  30. package/dist/pipelines/aiLabChatContract.js +30 -2
  31. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  32. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.js +30 -5
  34. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +4 -2
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  39. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  40. package/dist/pipelines/labChatPrompt.js +13 -14
  41. package/dist/pipelines/labChatPrompt.js.map +1 -1
  42. package/dist/routers/_app.d.ts +1185 -215
  43. package/dist/routers/_app.d.ts.map +1 -1
  44. package/dist/routers/_app.js +4 -2
  45. package/dist/routers/_app.js.map +1 -1
  46. package/dist/routers/admin.d.ts +0 -1
  47. package/dist/routers/admin.d.ts.map +1 -1
  48. package/dist/routers/conversation.d.ts +1 -0
  49. package/dist/routers/conversation.d.ts.map +1 -1
  50. package/dist/routers/labChat.d.ts +10 -0
  51. package/dist/routers/labChat.d.ts.map +1 -1
  52. package/dist/routers/labChat.js +6 -3
  53. package/dist/routers/labChat.js.map +1 -1
  54. package/dist/routers/marketplace.d.ts +41 -0
  55. package/dist/routers/marketplace.d.ts.map +1 -1
  56. package/dist/routers/marketplace.js +13 -3
  57. package/dist/routers/marketplace.js.map +1 -1
  58. package/dist/routers/message.d.ts +37 -4
  59. package/dist/routers/message.d.ts.map +1 -1
  60. package/dist/routers/message.js +24 -7
  61. package/dist/routers/message.js.map +1 -1
  62. package/dist/routers/module.d.ts +345 -0
  63. package/dist/routers/module.d.ts.map +1 -0
  64. package/dist/routers/module.js +82 -0
  65. package/dist/routers/module.js.map +1 -0
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routes/aiStream.js +32 -2
  75. package/dist/routes/aiStream.js.map +1 -1
  76. package/dist/seedDatabase.d.ts.map +1 -1
  77. package/dist/seedDatabase.js +107 -3
  78. package/dist/seedDatabase.js.map +1 -1
  79. package/dist/services/adminUser.d.ts +0 -1
  80. package/dist/services/adminUser.d.ts.map +1 -1
  81. package/dist/services/adminUser.js +7 -4
  82. package/dist/services/adminUser.js.map +1 -1
  83. package/dist/services/aiChatStream.d.ts +15 -0
  84. package/dist/services/aiChatStream.d.ts.map +1 -1
  85. package/dist/services/aiChatStream.js +144 -74
  86. package/dist/services/aiChatStream.js.map +1 -1
  87. package/dist/services/aiGeneration.d.ts +4 -0
  88. package/dist/services/aiGeneration.d.ts.map +1 -0
  89. package/dist/services/aiGeneration.js +88 -0
  90. package/dist/services/aiGeneration.js.map +1 -0
  91. package/dist/services/aiProposalTools.d.ts +4 -1
  92. package/dist/services/aiProposalTools.d.ts.map +1 -1
  93. package/dist/services/aiProposalTools.js +128 -3
  94. package/dist/services/aiProposalTools.js.map +1 -1
  95. package/dist/services/aiProposals.d.ts +70 -0
  96. package/dist/services/aiProposals.d.ts.map +1 -0
  97. package/dist/services/aiProposals.js +363 -0
  98. package/dist/services/aiProposals.js.map +1 -0
  99. package/dist/services/assignment.d.ts.map +1 -1
  100. package/dist/services/assignment.js +8 -3
  101. package/dist/services/assignment.js.map +1 -1
  102. package/dist/services/conversation.d.ts +1 -0
  103. package/dist/services/conversation.d.ts.map +1 -1
  104. package/dist/services/labChat.d.ts +10 -0
  105. package/dist/services/labChat.d.ts.map +1 -1
  106. package/dist/services/labChat.js +52 -3
  107. package/dist/services/labChat.js.map +1 -1
  108. package/dist/services/marketplace.d.ts +36 -0
  109. package/dist/services/marketplace.d.ts.map +1 -1
  110. package/dist/services/marketplace.js +30 -3
  111. package/dist/services/marketplace.js.map +1 -1
  112. package/dist/services/message.d.ts +24 -8
  113. package/dist/services/message.d.ts.map +1 -1
  114. package/dist/services/message.js +170 -117
  115. package/dist/services/message.js.map +1 -1
  116. package/dist/services/module.d.ts +288 -0
  117. package/dist/services/module.d.ts.map +1 -0
  118. package/dist/services/module.js +518 -0
  119. package/dist/services/module.js.map +1 -0
  120. package/dist/services/parentPortal.d.ts +14 -0
  121. package/dist/services/parentPortal.d.ts.map +1 -1
  122. package/dist/services/parentPortal.js +72 -2
  123. package/dist/services/parentPortal.js.map +1 -1
  124. package/dist/services/school.d.ts.map +1 -1
  125. package/dist/services/school.js +17 -5
  126. package/dist/services/school.js.map +1 -1
  127. package/dist/services/studentProgress.d.ts +50 -0
  128. package/dist/services/studentProgress.d.ts.map +1 -1
  129. package/dist/services/studentProgress.js +107 -2
  130. package/dist/services/studentProgress.js.map +1 -1
  131. package/dist/utils/inference.d.ts +10 -0
  132. package/dist/utils/inference.d.ts.map +1 -1
  133. package/dist/utils/inference.js +77 -2
  134. package/dist/utils/inference.js.map +1 -1
  135. package/dist/workers/processors/createRunJobWorker.js +17 -16
  136. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  137. package/package.json +1 -1
  138. package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
  139. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  140. package/prisma/schema.prisma +101 -0
  141. package/src/models/cloudStorage.ts +19 -0
  142. package/src/models/labChat.ts +34 -0
  143. package/src/models/marketplace.ts +16 -0
  144. package/src/models/module.ts +77 -0
  145. package/src/pipelines/aiLabChat.ts +79 -18
  146. package/src/pipelines/aiLabChatContract.ts +35 -0
  147. package/src/pipelines/aiNewtonChat.ts +34 -2
  148. package/src/pipelines/gradeWorksheet.ts +6 -0
  149. package/src/pipelines/labChatPrompt.ts +11 -12
  150. package/src/routers/_app.ts +2 -0
  151. package/src/routers/labChat.ts +5 -0
  152. package/src/routers/marketplace.ts +16 -0
  153. package/src/routers/message.ts +34 -5
  154. package/src/routers/module.ts +118 -0
  155. package/src/routers/parent.ts +11 -0
  156. package/src/routers/studentProgress.ts +15 -0
  157. package/src/routes/aiStream.ts +32 -0
  158. package/src/seedDatabase.ts +107 -2
  159. package/src/services/adminUser.ts +5 -2
  160. package/src/services/aiChatStream.ts +83 -7
  161. package/src/services/aiGeneration.ts +92 -0
  162. package/src/services/aiProposalTools.ts +166 -1
  163. package/src/services/aiProposals.ts +428 -0
  164. package/src/services/assignment.ts +12 -1
  165. package/src/services/labChat.ts +65 -0
  166. package/src/services/marketplace.ts +39 -0
  167. package/src/services/message.ts +205 -142
  168. package/src/services/module.ts +683 -0
  169. package/src/services/parentPortal.ts +85 -0
  170. package/src/services/school.ts +15 -3
  171. package/src/services/studentProgress.ts +135 -0
  172. package/src/utils/inference.ts +85 -0
  173. package/src/workers/processors/createRunJobWorker.ts +17 -17
  174. package/tests/unit/services/adminUser.test.ts +75 -0
  175. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Module service – Canvas-style modules ("pathways") for classes.
3
+ *
4
+ * Modules are ordered groups of content items (assignments, worksheets,
5
+ * files, announcements, discussions, external URLs, sub-headers) with
6
+ * completion requirements, prerequisites, unlock dates and optional
7
+ * sequential progression.
8
+ */
9
+ import { TRPCError } from "@trpc/server";
10
+ import {
11
+ CourseModuleItemType as ModuleItemType,
12
+ ModuleItemRequirementType,
13
+ ModuleItemProgressStatus,
14
+ Prisma,
15
+ } from "@prisma/client";
16
+ import { prisma } from "../lib/prisma.js";
17
+ import { logger } from "../utils/logger.js";
18
+ import {
19
+ findClassWithTeacher,
20
+ findModulesByClassId,
21
+ findModuleByClassAndId,
22
+ findModuleItemInClass,
23
+ findStudentProgressForClass,
24
+ upsertItemProgress,
25
+ moduleInclude,
26
+ } from "../models/module.js";
27
+
28
+ async function assertTeacher(userId: string, classId: string) {
29
+ const classData = await findClassWithTeacher(classId, userId);
30
+ if (!classData) {
31
+ throw new TRPCError({
32
+ code: "NOT_FOUND",
33
+ message: "Class not found or you are not a teacher",
34
+ });
35
+ }
36
+ return classData;
37
+ }
38
+
39
+ /* -------------------------------------------------------------------------- */
40
+ /* Module CRUD */
41
+ /* -------------------------------------------------------------------------- */
42
+
43
+ export async function listModulesRecord(userId: string, classId: string) {
44
+ await assertTeacher(userId, classId);
45
+ return findModulesByClassId(classId);
46
+ }
47
+
48
+ export async function createModuleRecord(
49
+ userId: string,
50
+ input: { classId: string; title: string; unlockAt?: Date | null; requireSequential?: boolean }
51
+ ) {
52
+ await assertTeacher(userId, input.classId);
53
+ const last = await prisma.module.findFirst({
54
+ where: { classId: input.classId },
55
+ orderBy: { position: "desc" },
56
+ select: { position: true },
57
+ });
58
+ return prisma.module.create({
59
+ data: {
60
+ classId: input.classId,
61
+ title: input.title,
62
+ position: (last?.position ?? 0) + 1,
63
+ unlockAt: input.unlockAt ?? null,
64
+ requireSequential: input.requireSequential ?? false,
65
+ },
66
+ include: moduleInclude,
67
+ });
68
+ }
69
+
70
+ export async function updateModuleRecord(
71
+ userId: string,
72
+ input: {
73
+ id: string;
74
+ classId: string;
75
+ title?: string;
76
+ unlockAt?: Date | null;
77
+ requireSequential?: boolean;
78
+ published?: boolean;
79
+ prerequisiteIds?: string[];
80
+ }
81
+ ) {
82
+ await assertTeacher(userId, input.classId);
83
+ const module = await findModuleByClassAndId(input.id, input.classId);
84
+ if (!module) {
85
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module not found" });
86
+ }
87
+
88
+ if (input.prerequisiteIds) {
89
+ const prereqs = await prisma.module.findMany({
90
+ where: { id: { in: input.prerequisiteIds }, classId: input.classId },
91
+ select: { id: true, position: true },
92
+ });
93
+ if (prereqs.length !== input.prerequisiteIds.length) {
94
+ throw new TRPCError({
95
+ code: "BAD_REQUEST",
96
+ message: "Prerequisite module not found in this class",
97
+ });
98
+ }
99
+ // Like Canvas, only earlier modules can be prerequisites (prevents cycles).
100
+ if (prereqs.some((p) => p.id === input.id || p.position >= module.position)) {
101
+ throw new TRPCError({
102
+ code: "BAD_REQUEST",
103
+ message: "Prerequisites must be modules positioned before this one",
104
+ });
105
+ }
106
+ }
107
+
108
+ return prisma.$transaction(async (tx) => {
109
+ if (input.prerequisiteIds) {
110
+ await tx.modulePrerequisite.deleteMany({ where: { moduleId: input.id } });
111
+ if (input.prerequisiteIds.length) {
112
+ await tx.modulePrerequisite.createMany({
113
+ data: input.prerequisiteIds.map((prerequisiteId) => ({
114
+ moduleId: input.id,
115
+ prerequisiteId,
116
+ })),
117
+ });
118
+ }
119
+ }
120
+ return tx.module.update({
121
+ where: { id: input.id },
122
+ data: {
123
+ ...(input.title !== undefined && { title: input.title }),
124
+ ...(input.unlockAt !== undefined && { unlockAt: input.unlockAt }),
125
+ ...(input.requireSequential !== undefined && {
126
+ requireSequential: input.requireSequential,
127
+ }),
128
+ ...(input.published !== undefined && { published: input.published }),
129
+ },
130
+ include: moduleInclude,
131
+ });
132
+ });
133
+ }
134
+
135
+ export async function deleteModuleRecord(
136
+ userId: string,
137
+ input: { id: string; classId: string }
138
+ ) {
139
+ await assertTeacher(userId, input.classId);
140
+ const module = await findModuleByClassAndId(input.id, input.classId);
141
+ if (!module) {
142
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module not found" });
143
+ }
144
+ await prisma.module.delete({ where: { id: input.id } });
145
+ return { id: input.id };
146
+ }
147
+
148
+ export async function reorderModulesRecord(
149
+ userId: string,
150
+ input: { classId: string; orderedIds: string[] }
151
+ ) {
152
+ await assertTeacher(userId, input.classId);
153
+ const modules = await prisma.module.findMany({
154
+ where: { classId: input.classId },
155
+ select: { id: true },
156
+ });
157
+ const classIds = new Set(modules.map((m) => m.id));
158
+ if (
159
+ input.orderedIds.length !== classIds.size ||
160
+ input.orderedIds.some((id) => !classIds.has(id))
161
+ ) {
162
+ throw new TRPCError({
163
+ code: "BAD_REQUEST",
164
+ message: "orderedIds must contain every module of the class exactly once",
165
+ });
166
+ }
167
+ await prisma.$transaction(
168
+ input.orderedIds.map((id, index) =>
169
+ prisma.module.update({ where: { id }, data: { position: index + 1 } })
170
+ )
171
+ );
172
+ return findModulesByClassId(input.classId);
173
+ }
174
+
175
+ /* -------------------------------------------------------------------------- */
176
+ /* Module items */
177
+ /* -------------------------------------------------------------------------- */
178
+
179
+ const CONTENT_TYPES: ModuleItemType[] = [
180
+ ModuleItemType.ASSIGNMENT,
181
+ ModuleItemType.WORKSHEET,
182
+ ModuleItemType.FILE,
183
+ ModuleItemType.ANNOUNCEMENT,
184
+ ModuleItemType.DISCUSSION,
185
+ ];
186
+
187
+ const SUBMITTABLE_TYPES: ModuleItemType[] = [
188
+ ModuleItemType.ASSIGNMENT,
189
+ ModuleItemType.WORKSHEET,
190
+ ];
191
+
192
+ async function validateItemInput(
193
+ classId: string,
194
+ input: {
195
+ itemType: ModuleItemType;
196
+ itemId?: string | null;
197
+ externalUrl?: string | null;
198
+ title?: string | null;
199
+ requirementType?: ModuleItemRequirementType | null;
200
+ requiredScore?: number | null;
201
+ }
202
+ ) {
203
+ const { itemType, itemId, externalUrl, title, requirementType } = input;
204
+
205
+ if (itemType === ModuleItemType.SUB_HEADER) {
206
+ if (!title) {
207
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Sub-header requires a title" });
208
+ }
209
+ if (requirementType) {
210
+ throw new TRPCError({
211
+ code: "BAD_REQUEST",
212
+ message: "Sub-headers cannot have completion requirements",
213
+ });
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (itemType === ModuleItemType.EXTERNAL_URL) {
219
+ if (!externalUrl || !title) {
220
+ throw new TRPCError({
221
+ code: "BAD_REQUEST",
222
+ message: "External URL items require a URL and a title",
223
+ });
224
+ }
225
+ } else if (CONTENT_TYPES.includes(itemType)) {
226
+ if (!itemId) {
227
+ throw new TRPCError({ code: "BAD_REQUEST", message: "itemId is required" });
228
+ }
229
+ const exists =
230
+ itemType === ModuleItemType.ASSIGNMENT
231
+ ? await prisma.assignment.findFirst({ where: { id: itemId, classId } })
232
+ : itemType === ModuleItemType.WORKSHEET
233
+ ? await prisma.worksheet.findFirst({ where: { id: itemId, classId } })
234
+ : itemType === ModuleItemType.ANNOUNCEMENT
235
+ ? await prisma.announcement.findFirst({ where: { id: itemId, classId } })
236
+ : itemType === ModuleItemType.DISCUSSION
237
+ ? await prisma.discussion.findFirst({ where: { id: itemId, classId } })
238
+ : await prisma.file.findUnique({ where: { id: itemId } });
239
+ if (!exists) {
240
+ throw new TRPCError({
241
+ code: "NOT_FOUND",
242
+ message: "Referenced content not found in this class",
243
+ });
244
+ }
245
+ }
246
+
247
+ if (
248
+ (requirementType === ModuleItemRequirementType.SUBMIT ||
249
+ requirementType === ModuleItemRequirementType.SCORE_AT_LEAST) &&
250
+ !SUBMITTABLE_TYPES.includes(itemType)
251
+ ) {
252
+ throw new TRPCError({
253
+ code: "BAD_REQUEST",
254
+ message: "Submit/score requirements only apply to assignments and worksheets",
255
+ });
256
+ }
257
+ if (
258
+ requirementType === ModuleItemRequirementType.SCORE_AT_LEAST &&
259
+ (input.requiredScore == null || input.requiredScore < 0)
260
+ ) {
261
+ throw new TRPCError({
262
+ code: "BAD_REQUEST",
263
+ message: "requiredScore is required for score requirements",
264
+ });
265
+ }
266
+ }
267
+
268
+ export async function createModuleItemRecord(
269
+ userId: string,
270
+ input: {
271
+ classId: string;
272
+ moduleId: string;
273
+ itemType: ModuleItemType;
274
+ itemId?: string;
275
+ externalUrl?: string;
276
+ title?: string;
277
+ indent?: number;
278
+ requirementType?: ModuleItemRequirementType;
279
+ requiredScore?: number;
280
+ }
281
+ ) {
282
+ await assertTeacher(userId, input.classId);
283
+ const module = await findModuleByClassAndId(input.moduleId, input.classId);
284
+ if (!module) {
285
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module not found" });
286
+ }
287
+ await validateItemInput(input.classId, input);
288
+
289
+ const last = await prisma.courseModuleItem.findFirst({
290
+ where: { moduleId: input.moduleId },
291
+ orderBy: { position: "desc" },
292
+ select: { position: true },
293
+ });
294
+
295
+ return prisma.courseModuleItem.create({
296
+ data: {
297
+ moduleId: input.moduleId,
298
+ position: (last?.position ?? 0) + 1,
299
+ indent: input.indent ?? 0,
300
+ itemType: input.itemType,
301
+ itemId: input.itemId ?? null,
302
+ externalUrl: input.externalUrl ?? null,
303
+ title: input.title ?? null,
304
+ requirementType: input.requirementType ?? null,
305
+ requiredScore:
306
+ input.requirementType === ModuleItemRequirementType.SCORE_AT_LEAST
307
+ ? input.requiredScore
308
+ : null,
309
+ },
310
+ });
311
+ }
312
+
313
+ export async function updateModuleItemRecord(
314
+ userId: string,
315
+ input: {
316
+ classId: string;
317
+ id: string;
318
+ title?: string | null;
319
+ externalUrl?: string;
320
+ indent?: number;
321
+ requirementType?: ModuleItemRequirementType | null;
322
+ requiredScore?: number | null;
323
+ }
324
+ ) {
325
+ await assertTeacher(userId, input.classId);
326
+ const item = await findModuleItemInClass(input.id, input.classId);
327
+ if (!item) {
328
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module item not found" });
329
+ }
330
+
331
+ const requirementType =
332
+ input.requirementType !== undefined ? input.requirementType : item.requirementType;
333
+ const requiredScore =
334
+ input.requiredScore !== undefined ? input.requiredScore : item.requiredScore;
335
+
336
+ await validateItemInput(input.classId, {
337
+ itemType: item.itemType,
338
+ itemId: item.itemId,
339
+ externalUrl: input.externalUrl !== undefined ? input.externalUrl : item.externalUrl,
340
+ title: input.title !== undefined ? input.title : item.title,
341
+ requirementType,
342
+ requiredScore,
343
+ });
344
+
345
+ return prisma.courseModuleItem.update({
346
+ where: { id: input.id },
347
+ data: {
348
+ ...(input.title !== undefined && { title: input.title }),
349
+ ...(input.externalUrl !== undefined && { externalUrl: input.externalUrl }),
350
+ ...(input.indent !== undefined && { indent: input.indent }),
351
+ ...(input.requirementType !== undefined && {
352
+ requirementType: input.requirementType,
353
+ }),
354
+ requiredScore:
355
+ requirementType === ModuleItemRequirementType.SCORE_AT_LEAST
356
+ ? requiredScore
357
+ : null,
358
+ },
359
+ });
360
+ }
361
+
362
+ export async function deleteModuleItemRecord(
363
+ userId: string,
364
+ input: { classId: string; id: string }
365
+ ) {
366
+ await assertTeacher(userId, input.classId);
367
+ const item = await findModuleItemInClass(input.id, input.classId);
368
+ if (!item) {
369
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module item not found" });
370
+ }
371
+ await prisma.courseModuleItem.delete({ where: { id: input.id } });
372
+ return { id: input.id };
373
+ }
374
+
375
+ /**
376
+ * Reorder items, supporting drag-and-drop across modules. `items` is the full
377
+ * new ordering: for every affected module, its items in display order.
378
+ */
379
+ export async function reorderModuleItemsRecord(
380
+ userId: string,
381
+ input: { classId: string; items: { id: string; moduleId: string }[] }
382
+ ) {
383
+ await assertTeacher(userId, input.classId);
384
+
385
+ const itemIds = input.items.map((i) => i.id);
386
+ const moduleIds = [...new Set(input.items.map((i) => i.moduleId))];
387
+
388
+ const [dbItems, dbModules] = await Promise.all([
389
+ prisma.courseModuleItem.findMany({
390
+ where: { id: { in: itemIds }, module: { classId: input.classId } },
391
+ select: { id: true },
392
+ }),
393
+ prisma.module.findMany({
394
+ where: { id: { in: moduleIds }, classId: input.classId },
395
+ select: { id: true },
396
+ }),
397
+ ]);
398
+ if (dbItems.length !== itemIds.length || dbModules.length !== moduleIds.length) {
399
+ throw new TRPCError({
400
+ code: "BAD_REQUEST",
401
+ message: "Item or module not found in this class",
402
+ });
403
+ }
404
+
405
+ const positionByModule = new Map<string, number>();
406
+ await prisma.$transaction(
407
+ input.items.map((item) => {
408
+ const next = (positionByModule.get(item.moduleId) ?? 0) + 1;
409
+ positionByModule.set(item.moduleId, next);
410
+ return prisma.courseModuleItem.update({
411
+ where: { id: item.id },
412
+ data: { moduleId: item.moduleId, position: next },
413
+ });
414
+ })
415
+ );
416
+ return findModulesByClassId(input.classId);
417
+ }
418
+
419
+ /* -------------------------------------------------------------------------- */
420
+ /* Student state & progress */
421
+ /* -------------------------------------------------------------------------- */
422
+
423
+ type ModuleWithItems = Prisma.ModuleGetPayload<{
424
+ include: typeof moduleInclude;
425
+ }>;
426
+
427
+ function isModuleComplete(
428
+ module: ModuleWithItems,
429
+ completedItemIds: Set<string>
430
+ ) {
431
+ const required = module.items.filter((i) => i.requirementType);
432
+ return required.every((i) => completedItemIds.has(i.id));
433
+ }
434
+
435
+ /**
436
+ * Compute the full module state for a student: lock status, per-item
437
+ * completion/accessibility and per-module progress percentages.
438
+ */
439
+ export async function getStudentModulesState(classId: string, studentId: string) {
440
+ const [modules, progress] = await Promise.all([
441
+ findModulesByClassId(classId, true),
442
+ findStudentProgressForClass(classId, studentId),
443
+ ]);
444
+
445
+ const completedItemIds = new Set(
446
+ progress
447
+ .filter((p) => p.status === ModuleItemProgressStatus.COMPLETE)
448
+ .map((p) => p.moduleItemId)
449
+ );
450
+
451
+ // Students open worksheets through the assignment they are attached to, so
452
+ // resolve each worksheet item to its containing assignment for navigation.
453
+ const worksheetIds = [
454
+ ...new Set(
455
+ modules
456
+ .flatMap((m) => m.items)
457
+ .filter((i) => i.itemType === ModuleItemType.WORKSHEET && i.itemId)
458
+ .map((i) => i.itemId as string)
459
+ ),
460
+ ];
461
+ const worksheets = worksheetIds.length
462
+ ? await prisma.worksheet.findMany({
463
+ where: { id: { in: worksheetIds } },
464
+ select: { id: true, assignments: { select: { id: true }, take: 1 } },
465
+ })
466
+ : [];
467
+ const assignmentIdByWorksheetId = new Map(
468
+ worksheets.map((w) => [w.id, w.assignments[0]?.id ?? null])
469
+ );
470
+
471
+ const completeModuleIds = new Set<string>();
472
+ for (const module of modules) {
473
+ if (isModuleComplete(module, completedItemIds)) {
474
+ completeModuleIds.add(module.id);
475
+ }
476
+ }
477
+
478
+ const now = new Date();
479
+ let continueItemId: string | null = null;
480
+ let continueModuleId: string | null = null;
481
+
482
+ const result = modules.map((module) => {
483
+ const publishedPrereqIds = module.prerequisites
484
+ .map((p) => p.prerequisiteId)
485
+ .filter((id) => modules.some((m) => m.id === id));
486
+ const lockedByPrerequisite = publishedPrereqIds.some(
487
+ (id) => !completeModuleIds.has(id)
488
+ );
489
+ const lockedByDate = !!module.unlockAt && module.unlockAt > now;
490
+ const locked = lockedByPrerequisite || lockedByDate;
491
+
492
+ const required = module.items.filter((i) => i.requirementType);
493
+ const completedRequired = required.filter((i) => completedItemIds.has(i.id));
494
+ const percentComplete = required.length
495
+ ? Math.round((completedRequired.length / required.length) * 100)
496
+ : completeModuleIds.has(module.id)
497
+ ? 100
498
+ : 0;
499
+
500
+ // With sequential progression, items after the first incomplete required
501
+ // item are not yet accessible.
502
+ let sequentialBlocked = false;
503
+ const items = module.items.map((item) => {
504
+ const completed = completedItemIds.has(item.id);
505
+ const accessible =
506
+ !locked && (!module.requireSequential || !sequentialBlocked);
507
+ if (
508
+ module.requireSequential &&
509
+ item.requirementType &&
510
+ !completed &&
511
+ !sequentialBlocked
512
+ ) {
513
+ sequentialBlocked = true;
514
+ }
515
+ if (
516
+ !continueItemId &&
517
+ !locked &&
518
+ accessible &&
519
+ item.requirementType &&
520
+ !completed
521
+ ) {
522
+ continueItemId = item.id;
523
+ continueModuleId = module.id;
524
+ }
525
+ return {
526
+ ...item,
527
+ completed,
528
+ accessible,
529
+ worksheetAssignmentId:
530
+ item.itemType === ModuleItemType.WORKSHEET && item.itemId
531
+ ? (assignmentIdByWorksheetId.get(item.itemId) ?? null)
532
+ : null,
533
+ };
534
+ });
535
+
536
+ return {
537
+ ...module,
538
+ items,
539
+ locked,
540
+ lockedByPrerequisite,
541
+ lockedByDate,
542
+ completed: completeModuleIds.has(module.id),
543
+ requiredCount: required.length,
544
+ completedRequiredCount: completedRequired.length,
545
+ percentComplete,
546
+ };
547
+ });
548
+
549
+ return {
550
+ modules: result,
551
+ continueItemId,
552
+ continueModuleId,
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Mark a module item as viewed by a student. Completes VIEW requirements;
558
+ * for other requirement types this only records that a progress row exists.
559
+ */
560
+ export async function markItemViewedRecord(
561
+ studentId: string,
562
+ input: { classId: string; itemId: string }
563
+ ) {
564
+ const item = await findModuleItemInClass(input.itemId, input.classId);
565
+ if (!item || !item.module.published) {
566
+ throw new TRPCError({ code: "NOT_FOUND", message: "Module item not found" });
567
+ }
568
+
569
+ const state = await getStudentModulesState(input.classId, studentId);
570
+ const moduleState = state.modules.find((m) => m.id === item.moduleId);
571
+ const itemState = moduleState?.items.find((i) => i.id === item.id);
572
+ if (!moduleState || !itemState || moduleState.locked || !itemState.accessible) {
573
+ throw new TRPCError({
574
+ code: "FORBIDDEN",
575
+ message: "This module item is locked",
576
+ });
577
+ }
578
+
579
+ if (item.requirementType === ModuleItemRequirementType.VIEW) {
580
+ return upsertItemProgress(item.id, studentId, "COMPLETE");
581
+ }
582
+ return upsertItemProgress(item.id, studentId, "INCOMPLETE");
583
+ }
584
+
585
+ /* -------------------------------------------------------------------------- */
586
+ /* Automatic completion hooks */
587
+ /* -------------------------------------------------------------------------- */
588
+
589
+ /**
590
+ * Update module progress for a student's assignment after a submission event
591
+ * (submitted or returned/graded). Safe to call from any flow; never throws.
592
+ */
593
+ export async function syncAssignmentModuleProgress(
594
+ studentId: string,
595
+ assignmentId: string
596
+ ) {
597
+ try {
598
+ const items = await prisma.courseModuleItem.findMany({
599
+ where: {
600
+ itemType: ModuleItemType.ASSIGNMENT,
601
+ itemId: assignmentId,
602
+ requirementType: {
603
+ in: [
604
+ ModuleItemRequirementType.SUBMIT,
605
+ ModuleItemRequirementType.SCORE_AT_LEAST,
606
+ ],
607
+ },
608
+ },
609
+ });
610
+ if (!items.length) return;
611
+
612
+ const submission = await prisma.submission.findFirst({
613
+ where: { assignmentId, studentId },
614
+ select: { submitted: true, returned: true, gradeReceived: true },
615
+ });
616
+ if (!submission) return;
617
+
618
+ for (const item of items) {
619
+ const complete =
620
+ item.requirementType === ModuleItemRequirementType.SUBMIT
621
+ ? !!submission.submitted
622
+ : !!submission.returned &&
623
+ submission.gradeReceived != null &&
624
+ submission.gradeReceived >= (item.requiredScore ?? 0);
625
+ if (complete) {
626
+ await upsertItemProgress(item.id, studentId, "COMPLETE");
627
+ }
628
+ }
629
+ } catch (error) {
630
+ logger.error("Failed to sync assignment module progress", {
631
+ error,
632
+ studentId,
633
+ assignmentId,
634
+ });
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Update module progress for a student's worksheet after grading completes.
640
+ * SUBMIT requirements complete immediately; SCORE_AT_LEAST compares the sum
641
+ * of earned points across the worksheet response. Never throws.
642
+ */
643
+ export async function syncWorksheetModuleProgress(
644
+ studentId: string,
645
+ worksheetId: string
646
+ ) {
647
+ try {
648
+ const items = await prisma.courseModuleItem.findMany({
649
+ where: {
650
+ itemType: ModuleItemType.WORKSHEET,
651
+ itemId: worksheetId,
652
+ requirementType: {
653
+ in: [
654
+ ModuleItemRequirementType.SUBMIT,
655
+ ModuleItemRequirementType.SCORE_AT_LEAST,
656
+ ],
657
+ },
658
+ },
659
+ });
660
+ if (!items.length) return;
661
+
662
+ const earned = await prisma.studentQuestionProgress.aggregate({
663
+ where: { studentId, question: { worksheetId } },
664
+ _sum: { points: true },
665
+ });
666
+ const totalPoints = earned._sum.points ?? 0;
667
+
668
+ for (const item of items) {
669
+ const complete =
670
+ item.requirementType === ModuleItemRequirementType.SUBMIT ||
671
+ totalPoints >= (item.requiredScore ?? 0);
672
+ if (complete) {
673
+ await upsertItemProgress(item.id, studentId, "COMPLETE");
674
+ }
675
+ }
676
+ } catch (error) {
677
+ logger.error("Failed to sync worksheet module progress", {
678
+ error,
679
+ studentId,
680
+ worksheetId,
681
+ });
682
+ }
683
+ }