@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
@@ -1 +1 @@
1
- {"version":3,"file":"transcript.js","sources":["services/transcript.ts"],"sourceRoot":"/","sourcesContent":["/**\n * School-admin branded transcripts / progress reports (A4 — Nepal pilot).\n *\n * Aggregates per-class gradebook grades + grading boundaries + attendance %\n * into a pdf-lib PDF. Branding: school name + logo (letterhead optional when\n * present on School; schema does not include it yet — do not block).\n *\n * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or\n * a free-text `schoolYear` string for the report header.\n *\n * Attendance is read via the existing present/late/absent M2M lists only\n * (same shaping as getStudentTimeline). Do not conflict with A1 Attendance\n * refactor — rate math lives in attendanceRate.ts.\n */\n\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { bucket } from \"../lib/googleCloudStorage.js\";\nimport {\n buildTranscriptPdf,\n isLevelBasedBoundary,\n type TranscriptClassRow,\n type TranscriptReportVariant,\n} from \"../lib/transcriptPdf.js\";\nimport {\n findActiveSchoolAdminMembership,\n getSchoolById,\n} from \"../models/school.js\";\nimport { computeAttendanceRate } from \"./attendanceRate.js\";\nimport { computeStudentGradeInClass } from \"./gradebook.js\";\n\nconst MAX_BULK = 50;\n\nexport interface GenerateTranscriptInput {\n schoolId: string;\n studentId: string;\n from?: Date;\n to?: Date;\n /**\n * Free-text school year label (e.g. \"2082 BS\", \"2025–2026\").\n * Prefer AcademicTerm (C1) once available.\n */\n schoolYear?: string;\n}\n\nasync function requireSchoolAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can generate transcripts\",\n });\n }\n return membership;\n}\n\nasync function loadFileBytes(\n path: string | null | undefined\n): Promise<Uint8Array | null> {\n if (!path) return null;\n try {\n const [bytes] = await bucket.file(path).download();\n return new Uint8Array(bytes);\n } catch {\n return null;\n }\n}\n\nfunction formatPeriodLabel(from?: Date, to?: Date): string | null {\n if (!from && !to) return null;\n const fmt = (d: Date) => d.toISOString().slice(0, 10);\n if (from && to) return `${fmt(from)} – ${fmt(to)}`;\n if (from) return `From ${fmt(from)}`;\n return `Through ${fmt(to!)}`;\n}\n\nfunction slugify(value: string): string {\n return value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 48);\n}\n\nasync function fetchStudentAttendance(\n studentId: string,\n from?: Date,\n to?: Date\n) {\n const range = {\n ...(from ? { gte: from } : {}),\n ...(to ? { lte: to } : {}),\n };\n const hasRange = Object.keys(range).length > 0;\n\n // Same M2M present/late/absent read path as getStudentTimeline (A1-safe).\n const attendance = await prisma.attendance.findMany({\n where: {\n OR: [\n { present: { some: { id: studentId } } },\n { late: { some: { id: studentId } } },\n { absent: { some: { id: studentId } } },\n ],\n ...(hasRange ? { date: range } : {}),\n },\n select: {\n id: true,\n present: { where: { id: studentId }, select: { id: true } },\n late: { where: { id: studentId }, select: { id: true } },\n absent: { where: { id: studentId }, select: { id: true } },\n },\n });\n\n return attendance.map((record) => ({\n status: record.present.length\n ? (\"PRESENT\" as const)\n : record.late.length\n ? (\"LATE\" as const)\n : (\"ABSENT\" as const),\n }));\n}\n\nasync function buildClassRows(\n schoolId: string,\n studentId: string\n): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {\n const classes = await prisma.class.findMany({\n where: {\n schoolId,\n students: { some: { id: studentId } },\n },\n select: {\n id: true,\n name: true,\n subject: true,\n section: true,\n },\n orderBy: [{ subject: \"asc\" }, { name: \"asc\" }],\n });\n\n const rows: TranscriptClassRow[] = [];\n let levelVotes = 0;\n let letterVotes = 0;\n\n for (const cls of classes) {\n const grade = await computeStudentGradeInClass(cls.id, studentId);\n if (!grade) continue;\n\n if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {\n levelVotes += 1;\n } else if (grade.gradingBoundary) {\n letterVotes += 1;\n }\n\n rows.push({\n className: cls.name,\n subject: cls.subject,\n section: cls.section,\n percentage: grade.result.percentage,\n gradeLabel: grade.letterGrade,\n categorySummaries: grade.result.categories.map((c) => ({\n name: c.name,\n average: c.average,\n })),\n });\n }\n\n const variant: TranscriptReportVariant =\n levelVotes > 0 && levelVotes >= letterVotes ? \"level\" : \"letter\";\n\n return { rows, variant };\n}\n\nexport async function generateTranscript(\n userId: string,\n input: GenerateTranscriptInput\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const school = await getSchoolById(input.schoolId);\n if (!school) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"School not found\" });\n }\n\n const membership = await prisma.schoolMembership.findFirst({\n where: {\n schoolId: input.schoolId,\n userId: input.studentId,\n status: \"ACTIVE\",\n role: \"STUDENT\",\n },\n select: {\n user: {\n select: {\n id: true,\n username: true,\n profile: { select: { displayName: true } },\n },\n },\n },\n });\n if (!membership) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: \"Student is not an active student in this school\",\n });\n }\n\n const studentName =\n membership.user.profile?.displayName || membership.user.username;\n\n const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([\n buildClassRows(input.schoolId, input.studentId),\n fetchStudentAttendance(input.studentId, input.from, input.to),\n loadFileBytes(school.logo?.path),\n ]);\n\n // Soft letterhead support when schema later adds it — never required.\n const schoolAny = school as {\n letterhead?: { path?: string | null; type?: string | null } | null;\n letterheadPath?: string | null;\n letterheadType?: string | null;\n };\n const letterheadPath =\n schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;\n const letterheadType =\n schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;\n const letterheadBytes = await loadFileBytes(letterheadPath);\n\n const attendance = computeAttendanceRate(attendanceStatuses);\n const generatedAt = new Date();\n\n const pdfBytes = await buildTranscriptPdf({\n schoolName: school.name,\n studentName,\n studentUsername: membership.user.username,\n schoolYear: input.schoolYear ?? null,\n periodLabel: formatPeriodLabel(input.from, input.to),\n variant,\n classes: rows,\n attendance,\n logoBytes,\n logoContentType: school.logo?.type ?? null,\n letterheadBytes,\n letterheadContentType: letterheadType,\n generatedAt,\n });\n\n const yearSlug = input.schoolYear\n ? slugify(input.schoolYear)\n : generatedAt.toISOString().slice(0, 10);\n const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;\n\n return {\n pdfBase64: Buffer.from(pdfBytes).toString(\"base64\"),\n fileName,\n variant,\n studentId: input.studentId,\n studentName,\n /** C1 blocker: AcademicTerm model does not exist yet. */\n academicTermSupported: false as const,\n attendance,\n classCount: rows.length,\n };\n}\n\nexport async function generateTranscripts(\n userId: string,\n input: {\n schoolId: string;\n studentIds: string[];\n from?: Date;\n to?: Date;\n schoolYear?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);\n if (uniqueIds.length === 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Select at least one student\",\n });\n }\n\n type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;\n const results: TranscriptResult[] = [];\n const errors: { studentId: string; message: string }[] = [];\n\n for (const studentId of uniqueIds) {\n try {\n const result = await generateTranscript(userId, {\n schoolId: input.schoolId,\n studentId,\n from: input.from,\n to: input.to,\n schoolYear: input.schoolYear,\n });\n results.push(result);\n } catch (error) {\n const message =\n error instanceof TRPCError\n ? error.message\n : error instanceof Error\n ? error.message\n : \"Failed to generate transcript\";\n errors.push({ studentId, message });\n }\n }\n\n return { transcripts: results, errors };\n}\n"],"names":[],"mappings":"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","debug_id":"c7eb65fe-e18f-5b5b-9897-ce16dc61c93d"}
1
+ {"version":3,"file":"transcript.js","sources":["services/transcript.ts"],"sourceRoot":"/","sourcesContent":["/**\n * School-admin branded transcripts / progress reports (A4 — Nepal pilot).\n *\n * Aggregates per-class gradebook grades + grading boundaries + attendance %\n * into a pdf-lib PDF. Branding: school name + logo (letterhead optional when\n * present on School; schema does not include it yet — do not block).\n *\n * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or\n * a free-text `schoolYear` string for the report header.\n *\n * Attendance is read from AttendanceRecord (EXCUSED excluded from the rate\n * denominator) — rate math lives in attendanceRate.ts.\n */\n\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { bucket } from \"../lib/googleCloudStorage.js\";\nimport {\n buildTranscriptPdf,\n isLevelBasedBoundary,\n type TranscriptClassRow,\n type TranscriptReportVariant,\n} from \"../lib/transcriptPdf.js\";\nimport {\n findActiveSchoolAdminMembership,\n getSchoolById,\n} from \"../models/school.js\";\nimport { computeAttendanceRate } from \"./attendanceRate.js\";\nimport { computeStudentGradeInClass } from \"./gradebook.js\";\n\nconst MAX_BULK = 50;\n\nexport interface GenerateTranscriptInput {\n schoolId: string;\n studentId: string;\n from?: Date;\n to?: Date;\n /**\n * Free-text school year label (e.g. \"2082 BS\", \"2025–2026\").\n * Prefer AcademicTerm (C1) once available.\n */\n schoolYear?: string;\n}\n\nasync function requireSchoolAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can generate transcripts\",\n });\n }\n return membership;\n}\n\nasync function loadFileBytes(\n path: string | null | undefined\n): Promise<Uint8Array | null> {\n if (!path) return null;\n try {\n const [bytes] = await bucket.file(path).download();\n return new Uint8Array(bytes);\n } catch {\n return null;\n }\n}\n\nfunction formatPeriodLabel(from?: Date, to?: Date): string | null {\n if (!from && !to) return null;\n const fmt = (d: Date) => d.toISOString().slice(0, 10);\n if (from && to) return `${fmt(from)} – ${fmt(to)}`;\n if (from) return `From ${fmt(from)}`;\n return `Through ${fmt(to!)}`;\n}\n\nfunction slugify(value: string): string {\n return value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 48);\n}\n\nasync function fetchStudentAttendance(\n studentId: string,\n from?: Date,\n to?: Date\n) {\n const range = {\n ...(from ? { gte: from } : {}),\n ...(to ? { lte: to } : {}),\n };\n const hasRange = Object.keys(range).length > 0;\n\n // Read AttendanceRecord so EXCUSED records are excluded from the rate\n // denominator (computeAttendanceRate ignores non-PRESENT/LATE/ABSENT).\n const records = await prisma.attendanceRecord.findMany({\n where: {\n studentId,\n status: { in: [\"PRESENT\", \"LATE\", \"ABSENT\"] },\n ...(hasRange ? { attendance: { date: range } } : {}),\n },\n select: { status: true },\n });\n\n return records.map((record) => ({\n status: record.status as \"PRESENT\" | \"LATE\" | \"ABSENT\",\n }));\n}\n\nasync function buildClassRows(\n schoolId: string,\n studentId: string\n): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {\n const classes = await prisma.class.findMany({\n where: {\n schoolId,\n students: { some: { id: studentId } },\n },\n select: {\n id: true,\n name: true,\n subject: true,\n section: true,\n },\n orderBy: [{ subject: \"asc\" }, { name: \"asc\" }],\n });\n\n const rows: TranscriptClassRow[] = [];\n let levelVotes = 0;\n let letterVotes = 0;\n\n for (const cls of classes) {\n const grade = await computeStudentGradeInClass(cls.id, studentId);\n if (!grade) continue;\n\n if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {\n levelVotes += 1;\n } else if (grade.gradingBoundary) {\n letterVotes += 1;\n }\n\n rows.push({\n className: cls.name,\n subject: cls.subject,\n section: cls.section,\n percentage: grade.result.percentage,\n gradeLabel: grade.letterGrade,\n categorySummaries: grade.result.categories.map((c) => ({\n name: c.name,\n average: c.average,\n })),\n });\n }\n\n const variant: TranscriptReportVariant =\n levelVotes > 0 && levelVotes >= letterVotes ? \"level\" : \"letter\";\n\n return { rows, variant };\n}\n\nexport async function generateTranscript(\n userId: string,\n input: GenerateTranscriptInput\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const school = await getSchoolById(input.schoolId);\n if (!school) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"School not found\" });\n }\n\n const membership = await prisma.schoolMembership.findFirst({\n where: {\n schoolId: input.schoolId,\n userId: input.studentId,\n status: \"ACTIVE\",\n role: \"STUDENT\",\n },\n select: {\n user: {\n select: {\n id: true,\n username: true,\n profile: { select: { displayName: true } },\n },\n },\n },\n });\n if (!membership) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: \"Student is not an active student in this school\",\n });\n }\n\n const studentName =\n membership.user.profile?.displayName || membership.user.username;\n\n const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([\n buildClassRows(input.schoolId, input.studentId),\n fetchStudentAttendance(input.studentId, input.from, input.to),\n loadFileBytes(school.logo?.path),\n ]);\n\n // Soft letterhead support when schema later adds it — never required.\n const schoolAny = school as {\n letterhead?: { path?: string | null; type?: string | null } | null;\n letterheadPath?: string | null;\n letterheadType?: string | null;\n };\n const letterheadPath =\n schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;\n const letterheadType =\n schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;\n const letterheadBytes = await loadFileBytes(letterheadPath);\n\n const attendance = computeAttendanceRate(attendanceStatuses);\n const generatedAt = new Date();\n\n const pdfBytes = await buildTranscriptPdf({\n schoolName: school.name,\n studentName,\n studentUsername: membership.user.username,\n schoolYear: input.schoolYear ?? null,\n periodLabel: formatPeriodLabel(input.from, input.to),\n variant,\n classes: rows,\n attendance,\n logoBytes,\n logoContentType: school.logo?.type ?? null,\n letterheadBytes,\n letterheadContentType: letterheadType,\n generatedAt,\n });\n\n const yearSlug = input.schoolYear\n ? slugify(input.schoolYear)\n : generatedAt.toISOString().slice(0, 10);\n const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;\n\n return {\n pdfBase64: Buffer.from(pdfBytes).toString(\"base64\"),\n fileName,\n variant,\n studentId: input.studentId,\n studentName,\n /** C1 blocker: AcademicTerm model does not exist yet. */\n academicTermSupported: false as const,\n attendance,\n classCount: rows.length,\n };\n}\n\nexport async function generateTranscripts(\n userId: string,\n input: {\n schoolId: string;\n studentIds: string[];\n from?: Date;\n to?: Date;\n schoolYear?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);\n if (uniqueIds.length === 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Select at least one student\",\n });\n }\n\n type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;\n const results: TranscriptResult[] = [];\n const errors: { studentId: string; message: string }[] = [];\n\n for (const studentId of uniqueIds) {\n try {\n const result = await generateTranscript(userId, {\n schoolId: input.schoolId,\n studentId,\n from: input.from,\n to: input.to,\n schoolYear: input.schoolYear,\n });\n results.push(result);\n } catch (error) {\n const message =\n error instanceof TRPCError\n ? error.message\n : error instanceof Error\n ? error.message\n : \"Failed to generate transcript\";\n errors.push({ studentId, message });\n }\n }\n\n return { transcripts: results, errors };\n}\n"],"names":[],"mappings":"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","debug_id":"186437e2-1c14-52c2-94d5-09ccd8035936"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -0,0 +1,100 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "CourseModuleItemType" AS ENUM ('ASSIGNMENT', 'WORKSHEET', 'FILE', 'ANNOUNCEMENT', 'DISCUSSION', 'EXTERNAL_URL', 'SUB_HEADER');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "ModuleItemRequirementType" AS ENUM ('VIEW', 'SUBMIT', 'SCORE_AT_LEAST');
6
+
7
+ -- CreateEnum
8
+ CREATE TYPE "ModuleItemProgressStatus" AS ENUM ('INCOMPLETE', 'COMPLETE');
9
+
10
+ -- CreateTable
11
+ CREATE TABLE "Module" (
12
+ "id" TEXT NOT NULL,
13
+ "classId" TEXT NOT NULL,
14
+ "title" TEXT NOT NULL,
15
+ "position" INTEGER NOT NULL,
16
+ "unlockAt" TIMESTAMP(3),
17
+ "requireSequential" BOOLEAN NOT NULL DEFAULT false,
18
+ "published" BOOLEAN NOT NULL DEFAULT false,
19
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20
+ "updatedAt" TIMESTAMP(3) NOT NULL,
21
+
22
+ CONSTRAINT "Module_pkey" PRIMARY KEY ("id")
23
+ );
24
+
25
+ -- CreateTable
26
+ CREATE TABLE "ModulePrerequisite" (
27
+ "id" TEXT NOT NULL,
28
+ "moduleId" TEXT NOT NULL,
29
+ "prerequisiteId" TEXT NOT NULL,
30
+
31
+ CONSTRAINT "ModulePrerequisite_pkey" PRIMARY KEY ("id")
32
+ );
33
+
34
+ -- CreateTable
35
+ CREATE TABLE "CourseModuleItem" (
36
+ "id" TEXT NOT NULL,
37
+ "moduleId" TEXT NOT NULL,
38
+ "position" INTEGER NOT NULL,
39
+ "indent" INTEGER NOT NULL DEFAULT 0,
40
+ "itemType" "CourseModuleItemType" NOT NULL,
41
+ "itemId" TEXT,
42
+ "externalUrl" TEXT,
43
+ "title" TEXT,
44
+ "requirementType" "ModuleItemRequirementType",
45
+ "requiredScore" DOUBLE PRECISION,
46
+
47
+ CONSTRAINT "CourseModuleItem_pkey" PRIMARY KEY ("id")
48
+ );
49
+
50
+ -- CreateTable
51
+ CREATE TABLE "ModuleItemProgress" (
52
+ "id" TEXT NOT NULL,
53
+ "moduleItemId" TEXT NOT NULL,
54
+ "studentId" TEXT NOT NULL,
55
+ "status" "ModuleItemProgressStatus" NOT NULL DEFAULT 'INCOMPLETE',
56
+ "completedAt" TIMESTAMP(3),
57
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+ "updatedAt" TIMESTAMP(3) NOT NULL,
59
+
60
+ CONSTRAINT "ModuleItemProgress_pkey" PRIMARY KEY ("id")
61
+ );
62
+
63
+ -- CreateIndex
64
+ CREATE INDEX "Module_classId_position_idx" ON "Module"("classId", "position");
65
+
66
+ -- CreateIndex
67
+ CREATE INDEX "ModulePrerequisite_prerequisiteId_idx" ON "ModulePrerequisite"("prerequisiteId");
68
+
69
+ -- CreateIndex
70
+ CREATE UNIQUE INDEX "ModulePrerequisite_moduleId_prerequisiteId_key" ON "ModulePrerequisite"("moduleId", "prerequisiteId");
71
+
72
+ -- CreateIndex
73
+ CREATE INDEX "CourseModuleItem_moduleId_position_idx" ON "CourseModuleItem"("moduleId", "position");
74
+
75
+ -- CreateIndex
76
+ CREATE INDEX "CourseModuleItem_itemType_itemId_idx" ON "CourseModuleItem"("itemType", "itemId");
77
+
78
+ -- CreateIndex
79
+ CREATE INDEX "ModuleItemProgress_studentId_idx" ON "ModuleItemProgress"("studentId");
80
+
81
+ -- CreateIndex
82
+ CREATE UNIQUE INDEX "ModuleItemProgress_moduleItemId_studentId_key" ON "ModuleItemProgress"("moduleItemId", "studentId");
83
+
84
+ -- AddForeignKey
85
+ ALTER TABLE "Module" ADD CONSTRAINT "Module_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
86
+
87
+ -- AddForeignKey
88
+ ALTER TABLE "ModulePrerequisite" ADD CONSTRAINT "ModulePrerequisite_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
89
+
90
+ -- AddForeignKey
91
+ ALTER TABLE "ModulePrerequisite" ADD CONSTRAINT "ModulePrerequisite_prerequisiteId_fkey" FOREIGN KEY ("prerequisiteId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
92
+
93
+ -- AddForeignKey
94
+ ALTER TABLE "CourseModuleItem" ADD CONSTRAINT "CourseModuleItem_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
95
+
96
+ -- AddForeignKey
97
+ ALTER TABLE "ModuleItemProgress" ADD CONSTRAINT "ModuleItemProgress_moduleItemId_fkey" FOREIGN KEY ("moduleItemId") REFERENCES "CourseModuleItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
98
+
99
+ -- AddForeignKey
100
+ ALTER TABLE "ModuleItemProgress" ADD CONSTRAINT "ModuleItemProgress_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -328,6 +328,7 @@ model User {
328
328
  discussions Discussion[]
329
329
  discussionReplies DiscussionReply[]
330
330
  discussionReads DiscussionRead[]
331
+ moduleItemProgress ModuleItemProgress[]
331
332
  customGradeEntries CustomGradeEntry[] @relation("UserCustomGradeEntries")
332
333
  fileAnnotations FileAnnotation[] @relation("AuthoredFileAnnotations")
333
334
  marketplaceListings MarketplaceListing[] @relation("MarketplaceListingAuthor")
@@ -428,6 +429,8 @@ model Class {
428
429
  studentsCanCreateDiscussions Boolean @default(true)
429
430
  studentsCanReply Boolean @default(true)
430
431
 
432
+ modules Module[]
433
+
431
434
  /// Teacher-controlled ClassSidebar visibility. Keys match nav hrefs
432
435
  /// without leading slash (e.g. "assignments", "quizzes"). Null = all defaults on.
433
436
  sidebarFeatures Json?
@@ -1478,6 +1481,96 @@ model Permission {
1478
1481
  @@index([schoolId])
1479
1482
  }
1480
1483
 
1484
+ enum CourseModuleItemType {
1485
+ ASSIGNMENT
1486
+ WORKSHEET
1487
+ FILE
1488
+ ANNOUNCEMENT
1489
+ DISCUSSION
1490
+ EXTERNAL_URL
1491
+ SUB_HEADER
1492
+ }
1493
+
1494
+ enum ModuleItemRequirementType {
1495
+ VIEW
1496
+ SUBMIT
1497
+ SCORE_AT_LEAST
1498
+ }
1499
+
1500
+ enum ModuleItemProgressStatus {
1501
+ INCOMPLETE
1502
+ COMPLETE
1503
+ }
1504
+
1505
+ /// Canvas-style course module: an ordered group of content items with
1506
+ /// optional prerequisites, unlock date and sequential progression.
1507
+ model Module {
1508
+ id String @id @default(uuid())
1509
+ classId String
1510
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
1511
+ title String
1512
+ position Int
1513
+ unlockAt DateTime?
1514
+ requireSequential Boolean @default(false)
1515
+ published Boolean @default(false)
1516
+ createdAt DateTime @default(now())
1517
+ updatedAt DateTime @updatedAt
1518
+
1519
+ items CourseModuleItem[]
1520
+ prerequisites ModulePrerequisite[] @relation("ModulePrerequisites")
1521
+ requiredBy ModulePrerequisite[] @relation("ModuleRequiredBy")
1522
+
1523
+ @@index([classId, position])
1524
+ }
1525
+
1526
+ model ModulePrerequisite {
1527
+ id String @id @default(uuid())
1528
+ moduleId String
1529
+ module Module @relation("ModulePrerequisites", fields: [moduleId], references: [id], onDelete: Cascade)
1530
+ prerequisiteId String
1531
+ prerequisite Module @relation("ModuleRequiredBy", fields: [prerequisiteId], references: [id], onDelete: Cascade)
1532
+
1533
+ @@unique([moduleId, prerequisiteId])
1534
+ @@index([prerequisiteId])
1535
+ }
1536
+
1537
+ model CourseModuleItem {
1538
+ id String @id @default(uuid())
1539
+ moduleId String
1540
+ module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
1541
+ position Int
1542
+ indent Int @default(0)
1543
+ itemType CourseModuleItemType
1544
+ /// ID of the referenced assignment / worksheet / file / announcement /
1545
+ /// discussion. Null for EXTERNAL_URL and SUB_HEADER items.
1546
+ itemId String?
1547
+ externalUrl String?
1548
+ /// Display title override; required for EXTERNAL_URL and SUB_HEADER.
1549
+ title String?
1550
+ requirementType ModuleItemRequirementType?
1551
+ requiredScore Float?
1552
+
1553
+ progress ModuleItemProgress[]
1554
+
1555
+ @@index([moduleId, position])
1556
+ @@index([itemType, itemId])
1557
+ }
1558
+
1559
+ model ModuleItemProgress {
1560
+ id String @id @default(uuid())
1561
+ moduleItemId String
1562
+ moduleItem CourseModuleItem @relation(fields: [moduleItemId], references: [id], onDelete: Cascade)
1563
+ studentId String
1564
+ student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
1565
+ status ModuleItemProgressStatus @default(INCOMPLETE)
1566
+ completedAt DateTime?
1567
+ createdAt DateTime @default(now())
1568
+ updatedAt DateTime @updatedAt
1569
+
1570
+ @@unique([moduleItemId, studentId])
1571
+ @@index([studentId])
1572
+ }
1573
+
1481
1574
  // ---------------------------------------------------------------------------
1482
1575
  // A2 — Monthly student fees + manual payment ledger (Nepal pilot)
1483
1576
  // Billing period = FeeInvoice.year + FeeInvoice.month (no separate term table;
@@ -93,6 +93,12 @@ function parseArgs(argv: string[]) {
93
93
  return out;
94
94
  }
95
95
 
96
+ function parseBool(value: string | boolean | undefined): boolean {
97
+ if (value === undefined) return false;
98
+ if (typeof value === "boolean") return value;
99
+ return !["false", "0", "no"].includes(value.trim().toLowerCase());
100
+ }
101
+
96
102
  function loadJson<T>(path: string): T {
97
103
  return JSON.parse(readFileSync(path, "utf8")) as T;
98
104
  }
@@ -174,13 +180,19 @@ async function main() {
174
180
  process.exit(1);
175
181
  }
176
182
 
177
- const dryRun = Boolean(args["dry-run"]);
178
- const force = Boolean(args.force);
179
- const skipBoundary = Boolean(args["skip-boundary"]);
180
- const grades = String(args.grades ?? "4,5")
183
+ const dryRun = parseBool(args["dry-run"]);
184
+ const force = parseBool(args.force);
185
+ const skipBoundary = parseBool(args["skip-boundary"]);
186
+ const rawGrades = String(args.grades ?? "4,5")
181
187
  .split(",")
182
- .map((g) => Number(g.trim()))
183
- .filter((g): g is 4 | 5 => g === 4 || g === 5);
188
+ .map((g) => Number(g.trim()));
189
+ const grades = rawGrades.filter((g): g is 4 | 5 => g === 4 || g === 5);
190
+ if (grades.length === 0 || grades.length !== rawGrades.length) {
191
+ console.error(
192
+ `Invalid --grades value: ${String(args.grades)}. Use 4, 5, or 4,5.`
193
+ );
194
+ process.exit(1);
195
+ }
184
196
  const subjectFilter = args.subjects
185
197
  ? String(args.subjects)
186
198
  .split(",")
@@ -219,6 +231,17 @@ async function main() {
219
231
  loadJson<SubjectFile>(join(PACK_DIR, "subjects", f))
220
232
  );
221
233
 
234
+ if (subjectFilter) {
235
+ const knownKeys = new Set(subjects.map((s) => s.key));
236
+ const unknown = subjectFilter.filter((key) => !knownKeys.has(key));
237
+ if (unknown.length > 0) {
238
+ console.error(
239
+ `Unknown --subjects value(s): ${unknown.join(", ")}. Known: ${[...knownKeys].join(", ")}`
240
+ );
241
+ process.exit(1);
242
+ }
243
+ }
244
+
222
245
  const units = subjects
223
246
  .filter((s) => !subjectFilter || subjectFilter.includes(s.key))
224
247
  .flatMap((s) => s.units)
@@ -370,18 +370,16 @@ function buildRecordsFromLists(input: {
370
370
  status: AttendanceStatus;
371
371
  updatedById?: string | null;
372
372
  }> = [];
373
- for (const studentId of input.presentIds) {
374
- records.push({ studentId, status: "PRESENT", updatedById: input.updatedById });
375
- }
376
- for (const studentId of input.lateIds) {
377
- records.push({ studentId, status: "LATE", updatedById: input.updatedById });
378
- }
379
- for (const studentId of input.absentIds) {
380
- records.push({ studentId, status: "ABSENT", updatedById: input.updatedById });
381
- }
382
- for (const studentId of input.excusedIds) {
383
- records.push({ studentId, status: "EXCUSED", updatedById: input.updatedById });
384
- }
373
+ const seen = new Set<string>();
374
+ const pushUnique = (studentId: string, status: AttendanceStatus) => {
375
+ if (seen.has(studentId)) return;
376
+ seen.add(studentId);
377
+ records.push({ studentId, status, updatedById: input.updatedById });
378
+ };
379
+ for (const studentId of input.presentIds) pushUnique(studentId, "PRESENT");
380
+ for (const studentId of input.lateIds) pushUnique(studentId, "LATE");
381
+ for (const studentId of input.absentIds) pushUnique(studentId, "ABSENT");
382
+ for (const studentId of input.excusedIds) pushUnique(studentId, "EXCUSED");
385
383
  return records;
386
384
  }
387
385
 
@@ -71,10 +71,13 @@ export async function searchMarketplaceListings(input: {
71
71
  limit: number;
72
72
  /** When true, include the caller's own hidden listings (for authors). */
73
73
  authorId?: string;
74
+ /** When set, restrict the entire result set (including PUBLIC listings) to this school. */
75
+ scopeSchoolId?: string;
74
76
  }) {
75
77
  const search = input.search?.trim();
76
78
  const where: Prisma.MarketplaceListingWhereInput = {
77
79
  AND: [
80
+ ...(input.scopeSchoolId ? [{ schoolId: input.scopeSchoolId }] : []),
78
81
  {
79
82
  OR: [
80
83
  { visibility: "PUBLIC", isHidden: false },
@@ -132,6 +135,22 @@ export async function searchMarketplaceListings(input: {
132
135
  return { items: rows, nextCursor };
133
136
  }
134
137
 
138
+ export async function updateMarketplaceListing(
139
+ id: string,
140
+ data: {
141
+ title?: string;
142
+ description?: string | null;
143
+ visibility?: MarketplaceVisibility;
144
+ tags?: string[];
145
+ }
146
+ ) {
147
+ return prisma.marketplaceListing.update({
148
+ where: { id },
149
+ data,
150
+ include: marketplaceListingInclude,
151
+ });
152
+ }
153
+
135
154
  export async function incrementMarketplaceReportCount(id: string) {
136
155
  return prisma.marketplaceListing.update({
137
156
  where: { id },
@@ -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
+ }
@@ -96,12 +96,30 @@ export function deleteModuleItem(id: string) {
96
96
  return prisma.moduleItem.delete({ where: { id } });
97
97
  }
98
98
 
99
+ function fileAccessibleToUserWhere(userId: string) {
100
+ return {
101
+ OR: [
102
+ { userId },
103
+ { folder: { personalOwnerId: userId } },
104
+ { folder: { class: { is: classTeacherOrSchoolAdminWhere(userId) } } },
105
+ ],
106
+ };
107
+ }
108
+
109
+ export function findAccessibleFilesByIds(fileIds: string[], userId: string) {
110
+ return prisma.file.findMany({
111
+ where: { id: { in: fileIds }, ...fileAccessibleToUserWhere(userId) },
112
+ select: { id: true, type: true },
113
+ });
114
+ }
115
+
99
116
  export function attachFilesToModuleItem(
100
117
  moduleItemId: string,
101
- fileIds: string[]
118
+ fileIds: string[],
119
+ userId: string
102
120
  ) {
103
121
  return prisma.file.updateMany({
104
- where: { id: { in: fileIds } },
122
+ where: { id: { in: fileIds }, ...fileAccessibleToUserWhere(userId) },
105
123
  data: { moduleItemId },
106
124
  });
107
125
  }
@@ -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 }) =>