@studious-lms/server 1.24.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 (117) hide show
  1. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +4 -4
  2. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1 -1
  3. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +4 -4
  4. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +4 -4
  5. package/dist/models/attendance.d.ts.map +1 -1
  6. package/dist/models/attendance.js +17 -14
  7. package/dist/models/attendance.js.map +1 -1
  8. package/dist/models/marketplace.d.ts +37 -0
  9. package/dist/models/marketplace.d.ts.map +1 -1
  10. package/dist/models/marketplace.js +10 -2
  11. package/dist/models/marketplace.js.map +1 -1
  12. package/dist/models/module.d.ts +228 -0
  13. package/dist/models/module.d.ts.map +1 -0
  14. package/dist/models/module.js +69 -0
  15. package/dist/models/module.js.map +1 -0
  16. package/dist/models/moduleItem.d.ts +5 -1
  17. package/dist/models/moduleItem.d.ts.map +1 -1
  18. package/dist/models/moduleItem.js +19 -4
  19. package/dist/models/moduleItem.js.map +1 -1
  20. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  21. package/dist/pipelines/gradeWorksheet.js +4 -2
  22. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  23. package/dist/routers/_app.d.ts +768 -6
  24. package/dist/routers/_app.d.ts.map +1 -1
  25. package/dist/routers/_app.js +4 -2
  26. package/dist/routers/_app.js.map +1 -1
  27. package/dist/routers/fees.d.ts +1 -1
  28. package/dist/routers/marketplace.d.ts +41 -0
  29. package/dist/routers/marketplace.d.ts.map +1 -1
  30. package/dist/routers/marketplace.js +13 -3
  31. package/dist/routers/marketplace.js.map +1 -1
  32. package/dist/routers/module.d.ts +345 -0
  33. package/dist/routers/module.d.ts.map +1 -0
  34. package/dist/routers/module.js +82 -0
  35. package/dist/routers/module.js.map +1 -0
  36. package/dist/routers/moduleItem.d.ts.map +1 -1
  37. package/dist/routers/moduleItem.js +7 -6
  38. package/dist/routers/moduleItem.js.map +1 -1
  39. package/dist/routers/parent.d.ts +2 -1
  40. package/dist/routers/parent.d.ts.map +1 -1
  41. package/dist/routers/school.d.ts +1 -1
  42. package/dist/routers/school.d.ts.map +1 -1
  43. package/dist/routers/school.js +14 -4
  44. package/dist/routers/school.js.map +1 -1
  45. package/dist/services/academicCalendar.d.ts.map +1 -1
  46. package/dist/services/academicCalendar.js +30 -2
  47. package/dist/services/academicCalendar.js.map +1 -1
  48. package/dist/services/assignment.d.ts.map +1 -1
  49. package/dist/services/assignment.js +8 -3
  50. package/dist/services/assignment.js.map +1 -1
  51. package/dist/services/attendance.d.ts +6 -2
  52. package/dist/services/attendance.d.ts.map +1 -1
  53. package/dist/services/attendance.js +8 -4
  54. package/dist/services/attendance.js.map +1 -1
  55. package/dist/services/class.d.ts +14 -9
  56. package/dist/services/class.d.ts.map +1 -1
  57. package/dist/services/class.js +4 -4
  58. package/dist/services/class.js.map +1 -1
  59. package/dist/services/fees.d.ts.map +1 -1
  60. package/dist/services/fees.js +24 -4
  61. package/dist/services/fees.js.map +1 -1
  62. package/dist/services/feesAuthz.d.ts +2 -5
  63. package/dist/services/feesAuthz.d.ts.map +1 -1
  64. package/dist/services/feesAuthz.js +5 -8
  65. package/dist/services/feesAuthz.js.map +1 -1
  66. package/dist/services/marketplace.d.ts +36 -0
  67. package/dist/services/marketplace.d.ts.map +1 -1
  68. package/dist/services/marketplace.js +36 -6
  69. package/dist/services/marketplace.js.map +1 -1
  70. package/dist/services/module.d.ts +288 -0
  71. package/dist/services/module.d.ts.map +1 -0
  72. package/dist/services/module.js +518 -0
  73. package/dist/services/module.js.map +1 -0
  74. package/dist/services/moduleItem.d.ts.map +1 -1
  75. package/dist/services/moduleItem.js +23 -7
  76. package/dist/services/moduleItem.js.map +1 -1
  77. package/dist/services/parentPortal.d.ts +3 -5
  78. package/dist/services/parentPortal.d.ts.map +1 -1
  79. package/dist/services/parentPortal.js +29 -43
  80. package/dist/services/parentPortal.js.map +1 -1
  81. package/dist/services/school.d.ts.map +1 -1
  82. package/dist/services/school.js +52 -16
  83. package/dist/services/school.js.map +1 -1
  84. package/dist/services/studentImport.d.ts.map +1 -1
  85. package/dist/services/studentImport.js +38 -25
  86. package/dist/services/studentImport.js.map +1 -1
  87. package/dist/services/transcript.d.ts +2 -3
  88. package/dist/services/transcript.d.ts.map +1 -1
  89. package/dist/services/transcript.js +13 -25
  90. package/dist/services/transcript.js.map +1 -1
  91. package/package.json +1 -1
  92. package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
  93. package/prisma/schema.prisma +93 -0
  94. package/scripts/import-nepal-cdc-curriculum.ts +29 -6
  95. package/src/models/attendance.ts +10 -12
  96. package/src/models/marketplace.ts +19 -0
  97. package/src/models/module.ts +77 -0
  98. package/src/models/moduleItem.ts +20 -2
  99. package/src/pipelines/gradeWorksheet.ts +6 -0
  100. package/src/routers/_app.ts +2 -0
  101. package/src/routers/marketplace.ts +16 -0
  102. package/src/routers/module.ts +118 -0
  103. package/src/routers/moduleItem.ts +6 -4
  104. package/src/routers/school.ts +16 -6
  105. package/src/services/academicCalendar.ts +28 -0
  106. package/src/services/assignment.ts +12 -1
  107. package/src/services/attendance.ts +7 -3
  108. package/src/services/class.ts +2 -2
  109. package/src/services/fees.ts +25 -2
  110. package/src/services/feesAuthz.ts +3 -7
  111. package/src/services/marketplace.ts +47 -3
  112. package/src/services/module.ts +683 -0
  113. package/src/services/moduleItem.ts +28 -4
  114. package/src/services/parentPortal.ts +26 -39
  115. package/src/services/school.ts +52 -15
  116. package/src/services/studentImport.ts +37 -25
  117. package/src/services/transcript.ts +11 -23
@@ -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
+ });
@@ -14,10 +14,12 @@ import {
14
14
  updateModuleItemRecord,
15
15
  } from "../services/moduleItem.js";
16
16
 
17
+ const MAX_MODULE_FILE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB (videos)
18
+
17
19
  const directFileSchema = z.object({
18
- name: z.string(),
19
- type: z.string(),
20
- size: z.number(),
20
+ name: z.string().trim().min(1).max(255),
21
+ type: z.string().trim().min(1).max(255),
22
+ size: z.number().int().positive().max(MAX_MODULE_FILE_BYTES),
21
23
  });
22
24
 
23
25
  const moduleItemTypeSchema = z.enum(["PAGE", "FILES", "VIDEO"]);
@@ -76,7 +78,7 @@ export const moduleItemRouter = createTRPCRouter({
76
78
  z.object({
77
79
  classId: z.string(),
78
80
  sectionId: z.string(),
79
- orderedIds: z.array(z.string()),
81
+ orderedIds: z.array(z.string()).min(1).max(500),
80
82
  })
81
83
  )
82
84
  .mutation(({ ctx, input }) => reorderModuleItems(ctx.user!.id, input)),
@@ -325,9 +325,14 @@ export const schoolRouter = createTRPCRouter({
325
325
  */
326
326
  generateTranscript: protectedProcedure
327
327
  .input(
328
- transcriptPeriodSchema.extend({
329
- studentId: z.string(),
330
- })
328
+ transcriptPeriodSchema
329
+ .extend({
330
+ studentId: z.string(),
331
+ })
332
+ .refine((v) => !v.from || !v.to || v.from <= v.to, {
333
+ message: "from must be on or before to",
334
+ path: ["from"],
335
+ })
331
336
  )
332
337
  .mutation(({ ctx, input }) => generateTranscript(ctx.user!.id, input)),
333
338
 
@@ -337,9 +342,14 @@ export const schoolRouter = createTRPCRouter({
337
342
  */
338
343
  generateTranscripts: protectedProcedure
339
344
  .input(
340
- transcriptPeriodSchema.extend({
341
- studentIds: z.array(z.string()).min(1).max(50),
342
- })
345
+ transcriptPeriodSchema
346
+ .extend({
347
+ studentIds: z.array(z.string()).min(1).max(50),
348
+ })
349
+ .refine((v) => !v.from || !v.to || v.from <= v.to, {
350
+ message: "from must be on or before to",
351
+ path: ["from"],
352
+ })
343
353
  )
344
354
  .mutation(({ ctx, input }) => generateTranscripts(ctx.user!.id, input)),
345
355
 
@@ -89,6 +89,20 @@ export async function updateYear(
89
89
  }
90
90
  ) {
91
91
  await requireSchoolAdmin(input.schoolId, userId);
92
+ if (input.startDate !== undefined || input.endDate !== undefined) {
93
+ const existing = await getAcademicYearById(input.yearId, input.schoolId);
94
+ if (!existing) {
95
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
96
+ }
97
+ const nextStart = input.startDate ?? existing.startDate;
98
+ const nextEnd = input.endDate ?? existing.endDate;
99
+ if (nextEnd < nextStart) {
100
+ throw new TRPCError({
101
+ code: "BAD_REQUEST",
102
+ message: "endDate must be on or after startDate",
103
+ });
104
+ }
105
+ }
92
106
  const year = await updateAcademicYear(input.yearId, input.schoolId, {
93
107
  name: input.name,
94
108
  startDate: input.startDate,
@@ -177,6 +191,20 @@ export async function updateTerm(
177
191
  if (!year) {
178
192
  throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
179
193
  }
194
+ if (input.startDate !== undefined || input.endDate !== undefined) {
195
+ const existingTerm = year.terms.find((t) => t.id === input.termId);
196
+ if (!existingTerm) {
197
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic term not found" });
198
+ }
199
+ const nextStart = input.startDate ?? existingTerm.startDate;
200
+ const nextEnd = input.endDate ?? existingTerm.endDate;
201
+ if (nextEnd < nextStart) {
202
+ throw new TRPCError({
203
+ code: "BAD_REQUEST",
204
+ message: "endDate must be on or after startDate",
205
+ });
206
+ }
207
+ }
180
208
  const term = await updateAcademicTerm(input.termId, input.yearId, {
181
209
  name: input.name,
182
210
  startDate: input.startDate,
@@ -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
@@ -291,7 +291,11 @@ export async function updateAttendanceRecord(
291
291
  return shapeAttendanceSession(updated);
292
292
  }
293
293
 
294
- /** School-admin: today's attendance rollup across classes. */
294
+ /**
295
+ * School-admin: today's attendance rollup across classes.
296
+ * `percentage` is session-weighted (share of attended class-session records),
297
+ * not a share of `totalStudents`; a student in six classes contributes six records.
298
+ */
295
299
  export async function listTodayAttendanceSummary(
296
300
  userId: string,
297
301
  schoolId: string
@@ -440,13 +444,13 @@ export async function listExcusals(
440
444
  const now = toDateOnly(new Date());
441
445
 
442
446
  return excusals.map((excusal) => {
443
- let status: "ACTIVE" | "UPCOMING" | "REVOKED" = "ACTIVE";
447
+ let status: "ACTIVE" | "UPCOMING" | "EXPIRED" | "REVOKED" = "ACTIVE";
444
448
  if (excusal.revokedAt) {
445
449
  status = "REVOKED";
446
450
  } else if (toDateOnly(excusal.startDate) > now) {
447
451
  status = "UPCOMING";
448
452
  } else if (toDateOnly(excusal.endDate) < now) {
449
- status = "ACTIVE"; // past but not revoked — still listed as ACTIVE historically
453
+ status = "EXPIRED";
450
454
  }
451
455
 
452
456
  return {
@@ -2,7 +2,7 @@
2
2
  * Class service – CRUD for classes, members, grades, mark schemes, grading boundaries,
3
3
  * syllabus, lab drafts, and invite codes.
4
4
  */
5
- import { SessionType } from "@prisma/client";
5
+ import { Prisma, SessionType } from "@prisma/client";
6
6
  import { TRPCError } from "@trpc/server";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { generateInviteCode } from "../utils/generateInviteCode.js";
@@ -247,7 +247,7 @@ export async function updateClass(
247
247
  studentsCanReply: data.studentsCanReply,
248
248
  }),
249
249
  ...(data.sidebarFeatures !== undefined && {
250
- sidebarFeatures: data.sidebarFeatures,
250
+ sidebarFeatures: data.sidebarFeatures ?? Prisma.DbNull,
251
251
  }),
252
252
  } as Parameters<typeof prisma.class.update>[0]["data"],
253
253
  select: {
@@ -39,6 +39,20 @@ async function requireAdmin(schoolId: string, userId: string) {
39
39
  return membership;
40
40
  }
41
41
 
42
+ async function assertActiveSchoolStudents(
43
+ schoolId: string,
44
+ studentIds: string[]
45
+ ) {
46
+ const activeIds = new Set(await listActiveStudentIds(schoolId));
47
+ const invalid = studentIds.filter((id) => !activeIds.has(id));
48
+ if (invalid.length > 0) {
49
+ throw new TRPCError({
50
+ code: "BAD_REQUEST",
51
+ message: "One or more students are not active members of this school",
52
+ });
53
+ }
54
+ }
55
+
42
56
  function assertMonth(month: number) {
43
57
  if (month < 1 || month > 12) {
44
58
  throw new TRPCError({
@@ -117,6 +131,7 @@ export async function createPlan(
117
131
  amountNpr: input.amountNpr,
118
132
  });
119
133
  if (input.studentIds?.length) {
134
+ await assertActiveSchoolStudents(input.schoolId, input.studentIds);
120
135
  const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
121
136
  return { plan: updated };
122
137
  }
@@ -162,6 +177,7 @@ export async function setPlanStudents(
162
177
  throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
163
178
  }
164
179
  await requireAdmin(existing.schoolId, userId);
180
+ await assertActiveSchoolStudents(existing.schoolId, input.studentIds);
165
181
  const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
166
182
  return { plan };
167
183
  }
@@ -192,7 +208,9 @@ export async function generateMonthlyInvoices(
192
208
  }
193
209
 
194
210
  let studentIds = input.studentIds;
195
- if (!studentIds?.length) {
211
+ if (studentIds?.length) {
212
+ await assertActiveSchoolStudents(input.schoolId, studentIds);
213
+ } else {
196
214
  const enrolled = await listPlanEnrollmentIds(plan.id);
197
215
  studentIds = enrolled.length
198
216
  ? enrolled
@@ -410,7 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
410
428
  },
411
429
  {
412
430
  label: "Paid at",
413
- value: payment.paidAt.toISOString().slice(0, 10),
431
+ value: new Intl.DateTimeFormat("en-CA", {
432
+ timeZone: "Asia/Kathmandu",
433
+ year: "numeric",
434
+ month: "2-digit",
435
+ day: "2-digit",
436
+ }).format(payment.paidAt),
414
437
  },
415
438
  ],
416
439
  footerNote: "Manual ledger receipt — gateway integration deferred.",
@@ -54,10 +54,7 @@ export function canManageFees(role: SchoolMembershipRole) {
54
54
  return FEE_WRITE_ROLES.includes(role);
55
55
  }
56
56
 
57
- /**
58
- * Placeholder procedure surface until A2 fee models exist.
59
- * Returns capability flags so the client can show/hide fee UI.
60
- */
57
+ /** Returns capability flags so the client can show/hide fee UI. */
61
58
  export async function getFeeCapabilities(userId: string, schoolId: string) {
62
59
  const membership = await findActiveSchoolMembership(schoolId, userId);
63
60
  if (!membership) {
@@ -70,8 +67,7 @@ export async function getFeeCapabilities(userId: string, schoolId: string) {
70
67
  role: membership.role,
71
68
  canManageFees: canManageFees(membership.role),
72
69
  canViewFees: FEE_READ_ROLES.includes(membership.role),
73
- feesImplemented: false as const,
74
- message:
75
- "Fee models are not implemented on this branch yet (owned by A2). Authz stubs are ready.",
70
+ feesImplemented: true as const,
71
+ message: "Fee features are available.",
76
72
  };
77
73
  }
@@ -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,
@@ -146,14 +147,19 @@ export async function listMarketplace(
146
147
  }
147
148
  ) {
148
149
  const memberships = await listUserSchoolMemberships(userId);
150
+ if (
151
+ input.schoolId &&
152
+ !memberships.some((m) => m.schoolId === input.schoolId)
153
+ ) {
154
+ return { items: [], nextCursor: undefined };
155
+ }
149
156
  const schoolIds = input.schoolId
150
- ? memberships.some((m) => m.schoolId === input.schoolId)
151
- ? [input.schoolId]
152
- : []
157
+ ? [input.schoolId]
153
158
  : memberships.map((m) => m.schoolId);
154
159
 
155
160
  return searchMarketplaceListings({
156
161
  schoolIds,
162
+ scopeSchoolId: input.schoolId,
157
163
  type: input.type,
158
164
  search: input.search,
159
165
  tags: input.tags,
@@ -654,6 +660,44 @@ export async function reportMarketplaceListing(
654
660
  return incrementMarketplaceReportCount(listingId);
655
661
  }
656
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
+
657
701
  export async function hideMarketplaceListing(
658
702
  userId: string,
659
703
  listingId: string,