@studious-lms/server 1.25.0 → 1.26.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 (50) hide show
  1. package/dist/models/marketplace.d.ts +35 -0
  2. package/dist/models/marketplace.d.ts.map +1 -1
  3. package/dist/models/marketplace.js +9 -2
  4. package/dist/models/marketplace.js.map +1 -1
  5. package/dist/models/module.d.ts +228 -0
  6. package/dist/models/module.d.ts.map +1 -0
  7. package/dist/models/module.js +69 -0
  8. package/dist/models/module.js.map +1 -0
  9. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  10. package/dist/pipelines/gradeWorksheet.js +4 -2
  11. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  12. package/dist/routers/_app.d.ts +760 -0
  13. package/dist/routers/_app.d.ts.map +1 -1
  14. package/dist/routers/_app.js +4 -2
  15. package/dist/routers/_app.js.map +1 -1
  16. package/dist/routers/marketplace.d.ts +41 -0
  17. package/dist/routers/marketplace.d.ts.map +1 -1
  18. package/dist/routers/marketplace.js +13 -3
  19. package/dist/routers/marketplace.js.map +1 -1
  20. package/dist/routers/module.d.ts +345 -0
  21. package/dist/routers/module.d.ts.map +1 -0
  22. package/dist/routers/module.js +82 -0
  23. package/dist/routers/module.js.map +1 -0
  24. package/dist/services/assignment.d.ts.map +1 -1
  25. package/dist/services/assignment.js +8 -3
  26. package/dist/services/assignment.js.map +1 -1
  27. package/dist/services/marketplace.d.ts +36 -0
  28. package/dist/services/marketplace.d.ts.map +1 -1
  29. package/dist/services/marketplace.js +30 -3
  30. package/dist/services/marketplace.js.map +1 -1
  31. package/dist/services/module.d.ts +288 -0
  32. package/dist/services/module.d.ts.map +1 -0
  33. package/dist/services/module.js +518 -0
  34. package/dist/services/module.js.map +1 -0
  35. package/dist/services/school.d.ts.map +1 -1
  36. package/dist/services/school.js +17 -5
  37. package/dist/services/school.js.map +1 -1
  38. package/package.json +1 -1
  39. package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
  40. package/prisma/schema.prisma +93 -0
  41. package/src/models/marketplace.ts +16 -0
  42. package/src/models/module.ts +77 -0
  43. package/src/pipelines/gradeWorksheet.ts +6 -0
  44. package/src/routers/_app.ts +2 -0
  45. package/src/routers/marketplace.ts +16 -0
  46. package/src/routers/module.ts +118 -0
  47. package/src/services/assignment.ts +12 -1
  48. package/src/services/marketplace.ts +39 -0
  49. package/src/services/module.ts +683 -0
  50. package/src/services/school.ts +15 -3
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Module model – Canvas-style class modules, items and student progress.
3
+ */
4
+ import { prisma } from "../lib/prisma.js";
5
+ import { classTeacherOrSchoolAdminWhere } from "./access.js";
6
+
7
+ /** @returns Class if user is a teacher or school admin. */
8
+ export function findClassWithTeacher(classId: string, teacherId: string) {
9
+ return prisma.class.findFirst({
10
+ where: {
11
+ id: classId,
12
+ ...classTeacherOrSchoolAdminWhere(teacherId),
13
+ },
14
+ });
15
+ }
16
+
17
+ export const moduleInclude = {
18
+ items: {
19
+ orderBy: { position: "asc" as const },
20
+ },
21
+ prerequisites: {
22
+ select: { prerequisiteId: true },
23
+ },
24
+ } as const;
25
+
26
+ /** @returns All modules of a class ordered by position. */
27
+ export function findModulesByClassId(classId: string, publishedOnly = false) {
28
+ return prisma.module.findMany({
29
+ where: { classId, ...(publishedOnly && { published: true }) },
30
+ orderBy: { position: "asc" },
31
+ include: moduleInclude,
32
+ });
33
+ }
34
+
35
+ /** @returns Module by ID scoped to a class. */
36
+ export function findModuleByClassAndId(id: string, classId: string) {
37
+ return prisma.module.findFirst({
38
+ where: { id, classId },
39
+ include: moduleInclude,
40
+ });
41
+ }
42
+
43
+ /** @returns Module item with its module, scoped to a class. */
44
+ export function findModuleItemInClass(itemId: string, classId: string) {
45
+ return prisma.courseModuleItem.findFirst({
46
+ where: { id: itemId, module: { classId } },
47
+ include: { module: true },
48
+ });
49
+ }
50
+
51
+ /** @returns Progress rows of a student for all items of a class. */
52
+ export function findStudentProgressForClass(classId: string, studentId: string) {
53
+ return prisma.moduleItemProgress.findMany({
54
+ where: { studentId, moduleItem: { module: { classId } } },
55
+ });
56
+ }
57
+
58
+ /** Upsert a progress row for a student and item. */
59
+ export function upsertItemProgress(
60
+ moduleItemId: string,
61
+ studentId: string,
62
+ status: "INCOMPLETE" | "COMPLETE"
63
+ ) {
64
+ return prisma.moduleItemProgress.upsert({
65
+ where: { moduleItemId_studentId: { moduleItemId, studentId } },
66
+ create: {
67
+ moduleItemId,
68
+ studentId,
69
+ status,
70
+ ...(status === "COMPLETE" && { completedAt: new Date() }),
71
+ },
72
+ update:
73
+ status === "COMPLETE"
74
+ ? { status, completedAt: new Date() }
75
+ : {},
76
+ });
77
+ }
@@ -9,6 +9,7 @@ import z from "zod";
9
9
  import { inference } from "../utils/inference.js";
10
10
  import { getAIUserId } from "../utils/aiUser.js";
11
11
  import { pusher, worksheetChannel } from "../lib/pusher.js";
12
+ import { syncWorksheetModuleProgress } from "../services/module.js";
12
13
 
13
14
 
14
15
  const removeAllPreviousAIComments = async (worksheetQuestionProgressId: string) => {
@@ -369,6 +370,11 @@ export const gradeWorksheetPipeline = async (
369
370
  }
370
371
 
371
372
  };
373
+
374
+ await syncWorksheetModuleProgress(
375
+ worksheetResponse.studentId,
376
+ worksheetResponse.worksheetId,
377
+ );
372
378
  };
373
379
 
374
380
  export const cancelGradePipeline = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
@@ -29,6 +29,7 @@ import { permissionsRouter } from "./permissions.js";
29
29
  import { discussionRouter } from "./discussion.js";
30
30
  import { adminRouter } from "./admin.js";
31
31
  import { embeddingRouter } from "./embedding.js";
32
+ import { moduleRouter } from "./module.js";
32
33
  import { gradebookRouter } from "./gradebook.js";
33
34
  import { feesRouter } from "./fees.js";
34
35
  import { marketplaceRouter } from "./marketplace.js";
@@ -65,6 +66,7 @@ export const appRouter = createTRPCRouter({
65
66
  discussion: discussionRouter,
66
67
  admin: adminRouter,
67
68
  embedding: embeddingRouter,
69
+ module: moduleRouter,
68
70
  gradebook: gradebookRouter,
69
71
  fees: feesRouter,
70
72
  marketplace: marketplaceRouter,
@@ -15,6 +15,7 @@ import {
15
15
  publishWorksheetListing,
16
16
  reportMarketplaceListing,
17
17
  unpublishMarketplaceListing,
18
+ updateMarketplaceListingDetails,
18
19
  } from "../services/marketplace.js";
19
20
 
20
21
  const listingTypeSchema = z.enum(["MARKSCHEME", "WORKSHEET", "FILE"]);
@@ -109,6 +110,21 @@ export const marketplaceRouter = createTRPCRouter({
109
110
  importMarketplaceListing(ctx.user!.id, input)
110
111
  ),
111
112
 
113
+ /** Edit listing metadata (author or school admin). */
114
+ update: protectedProcedure
115
+ .input(
116
+ z.object({
117
+ listingId: z.string(),
118
+ title: z.string().trim().min(1).max(160).optional(),
119
+ description: z.string().trim().max(2000).nullable().optional(),
120
+ visibility: visibilitySchema.optional(),
121
+ tags: tagsSchema,
122
+ })
123
+ )
124
+ .mutation(({ ctx, input }) =>
125
+ updateMarketplaceListingDetails(ctx.user!.id, input)
126
+ ),
127
+
112
128
  report: protectedProcedure
113
129
  .input(z.object({ listingId: z.string() }))
114
130
  .mutation(({ ctx, input }) =>
@@ -0,0 +1,118 @@
1
+ import { z } from "zod";
2
+ import { CourseModuleItemType as ModuleItemType, ModuleItemRequirementType } from "@prisma/client";
3
+ import {
4
+ createTRPCRouter,
5
+ protectedClassMemberProcedure,
6
+ protectedTeacherProcedure,
7
+ } from "../trpc.js";
8
+ import {
9
+ listModulesRecord,
10
+ createModuleRecord,
11
+ updateModuleRecord,
12
+ deleteModuleRecord,
13
+ reorderModulesRecord,
14
+ createModuleItemRecord,
15
+ updateModuleItemRecord,
16
+ deleteModuleItemRecord,
17
+ reorderModuleItemsRecord,
18
+ getStudentModulesState,
19
+ markItemViewedRecord,
20
+ } from "../services/module.js";
21
+
22
+ const itemTypeSchema = z.nativeEnum(ModuleItemType);
23
+ const requirementTypeSchema = z.nativeEnum(ModuleItemRequirementType);
24
+
25
+ export const moduleRouter = createTRPCRouter({
26
+ /** Teacher view: every module (published or not) with its items. */
27
+ list: protectedTeacherProcedure
28
+ .input(z.object({ classId: z.string() }))
29
+ .query(({ ctx, input }) => listModulesRecord(ctx.user!.id, input.classId)),
30
+
31
+ /** Student view: published modules with lock state and progress. */
32
+ getStudentState: protectedClassMemberProcedure
33
+ .input(z.object({ classId: z.string() }))
34
+ .query(({ ctx, input }) =>
35
+ getStudentModulesState(input.classId, ctx.user!.id)
36
+ ),
37
+
38
+ create: protectedTeacherProcedure
39
+ .input(
40
+ z.object({
41
+ classId: z.string(),
42
+ title: z.string().min(1),
43
+ unlockAt: z.coerce.date().nullish(),
44
+ requireSequential: z.boolean().optional(),
45
+ })
46
+ )
47
+ .mutation(({ ctx, input }) => createModuleRecord(ctx.user!.id, input)),
48
+
49
+ update: protectedTeacherProcedure
50
+ .input(
51
+ z.object({
52
+ classId: z.string(),
53
+ id: z.string(),
54
+ title: z.string().min(1).optional(),
55
+ unlockAt: z.coerce.date().nullable().optional(),
56
+ requireSequential: z.boolean().optional(),
57
+ published: z.boolean().optional(),
58
+ prerequisiteIds: z.array(z.string()).optional(),
59
+ })
60
+ )
61
+ .mutation(({ ctx, input }) => updateModuleRecord(ctx.user!.id, input)),
62
+
63
+ delete: protectedTeacherProcedure
64
+ .input(z.object({ classId: z.string(), id: z.string() }))
65
+ .mutation(({ ctx, input }) => deleteModuleRecord(ctx.user!.id, input)),
66
+
67
+ reorder: protectedTeacherProcedure
68
+ .input(z.object({ classId: z.string(), orderedIds: z.array(z.string()) }))
69
+ .mutation(({ ctx, input }) => reorderModulesRecord(ctx.user!.id, input)),
70
+
71
+ createItem: protectedTeacherProcedure
72
+ .input(
73
+ z.object({
74
+ classId: z.string(),
75
+ moduleId: z.string(),
76
+ itemType: itemTypeSchema,
77
+ itemId: z.string().optional(),
78
+ externalUrl: z.string().url().optional(),
79
+ title: z.string().optional(),
80
+ indent: z.number().int().min(0).max(4).optional(),
81
+ requirementType: requirementTypeSchema.optional(),
82
+ requiredScore: z.number().min(0).optional(),
83
+ })
84
+ )
85
+ .mutation(({ ctx, input }) => createModuleItemRecord(ctx.user!.id, input)),
86
+
87
+ updateItem: protectedTeacherProcedure
88
+ .input(
89
+ z.object({
90
+ classId: z.string(),
91
+ id: z.string(),
92
+ title: z.string().nullable().optional(),
93
+ externalUrl: z.string().url().optional(),
94
+ indent: z.number().int().min(0).max(4).optional(),
95
+ requirementType: requirementTypeSchema.nullable().optional(),
96
+ requiredScore: z.number().min(0).nullable().optional(),
97
+ })
98
+ )
99
+ .mutation(({ ctx, input }) => updateModuleItemRecord(ctx.user!.id, input)),
100
+
101
+ deleteItem: protectedTeacherProcedure
102
+ .input(z.object({ classId: z.string(), id: z.string() }))
103
+ .mutation(({ ctx, input }) => deleteModuleItemRecord(ctx.user!.id, input)),
104
+
105
+ reorderItems: protectedTeacherProcedure
106
+ .input(
107
+ z.object({
108
+ classId: z.string(),
109
+ items: z.array(z.object({ id: z.string(), moduleId: z.string() })),
110
+ })
111
+ )
112
+ .mutation(({ ctx, input }) => reorderModuleItemsRecord(ctx.user!.id, input)),
113
+
114
+ /** Record that a student viewed an item (completes VIEW requirements). */
115
+ markItemViewed: protectedClassMemberProcedure
116
+ .input(z.object({ classId: z.string(), itemId: z.string() }))
117
+ .mutation(({ ctx, input }) => markItemViewedRecord(ctx.user!.id, input)),
118
+ });
@@ -28,6 +28,7 @@ import { enqueueRunJob } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
30
  import { enqueueEmbedding } from "./embedding.js";
31
+ import { syncAssignmentModuleProgress } from "./module.js";
31
32
  import { resolveAssignmentBehavior } from "../lib/assignmentTypeDefaults.js";
32
33
 
33
34
  export async function assignmentExists(id: string) {
@@ -803,7 +804,7 @@ export async function updateSubmissionRecord(
803
804
  }
804
805
  }
805
806
  }
806
- return prisma.submission.update({
807
+ const updatedSubmission = await prisma.submission.update({
807
808
  where: { id: submission.id },
808
809
  data: {
809
810
  submitted: !submission.submitted,
@@ -821,6 +822,8 @@ export async function updateSubmissionRecord(
821
822
  },
822
823
  },
823
824
  });
825
+ await syncAssignmentModuleProgress(userId, submission.assignmentId);
826
+ return updatedSubmission;
824
827
  }
825
828
 
826
829
  const isLateUpdate = submission.assignment.dueDate < new Date();
@@ -1002,6 +1005,10 @@ export async function updateSubmissionAsTeacherRecord(
1002
1005
  data: { returned: !submission.returned },
1003
1006
  include: submissionTeacherInclude,
1004
1007
  });
1008
+ await syncAssignmentModuleProgress(
1009
+ submission.studentId,
1010
+ submission.assignmentId
1011
+ );
1005
1012
 
1006
1013
  if (updated.returned) {
1007
1014
  sendNotification({
@@ -1072,6 +1079,10 @@ export async function updateSubmissionAsTeacherRecord(
1072
1079
  submission.assignment.classId,
1073
1080
  submission.studentId,
1074
1081
  );
1082
+ await syncAssignmentModuleProgress(
1083
+ submission.studentId,
1084
+ submission.assignmentId
1085
+ );
1075
1086
  }
1076
1087
 
1077
1088
  // Index submission for RAG search after teacher grades/feedback
@@ -21,6 +21,7 @@ import {
21
21
  incrementMarketplaceReportCount,
22
22
  searchMarketplaceListings,
23
23
  setMarketplaceListingHidden,
24
+ updateMarketplaceListing,
24
25
  } from "../models/marketplace.js";
25
26
  import {
26
27
  findActiveSchoolAdminMembership,
@@ -659,6 +660,44 @@ export async function reportMarketplaceListing(
659
660
  return incrementMarketplaceReportCount(listingId);
660
661
  }
661
662
 
663
+ export async function updateMarketplaceListingDetails(
664
+ userId: string,
665
+ input: {
666
+ listingId: string;
667
+ title?: string;
668
+ description?: string | null;
669
+ visibility?: "SCHOOL" | "PUBLIC";
670
+ tags?: string[];
671
+ }
672
+ ) {
673
+ const listing = await findMarketplaceListingById(input.listingId);
674
+ if (!listing) {
675
+ throw new TRPCError({ code: "NOT_FOUND", message: "Listing not found" });
676
+ }
677
+ const isAuthor = listing.authorId === userId;
678
+ const isSchoolAdmin =
679
+ listing.schoolId != null &&
680
+ Boolean(await findActiveSchoolAdminMembership(listing.schoolId, userId));
681
+ if (!isAuthor && !isSchoolAdmin) {
682
+ throw new TRPCError({
683
+ code: "FORBIDDEN",
684
+ message: "Only the author or a school admin can edit this listing",
685
+ });
686
+ }
687
+ if (input.visibility === "SCHOOL" && listing.schoolId == null) {
688
+ throw new TRPCError({
689
+ code: "BAD_REQUEST",
690
+ message: "Listing has no school; it cannot use school visibility",
691
+ });
692
+ }
693
+ return updateMarketplaceListing(input.listingId, {
694
+ title: input.title,
695
+ description: input.description,
696
+ visibility: input.visibility,
697
+ tags: input.tags ? normalizeTags(input.tags) : undefined,
698
+ });
699
+ }
700
+
662
701
  export async function hideMarketplaceListing(
663
702
  userId: string,
664
703
  listingId: string,