@studious-lms/server 1.22.0 → 1.24.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 (298) hide show
  1. package/.env.example +0 -1
  2. package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
  3. package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
  4. package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
  5. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
  6. package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
  7. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
  8. package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
  9. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
  10. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
  11. package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
  12. package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
  13. package/dist/lib/assignmentTypeDefaults.js +167 -0
  14. package/dist/lib/assignmentTypeDefaults.js.map +1 -0
  15. package/dist/lib/config/env.d.ts +0 -8
  16. package/dist/lib/config/env.d.ts.map +1 -1
  17. package/dist/lib/config/env.js +2 -5
  18. package/dist/lib/config/env.js.map +1 -1
  19. package/dist/lib/feePdf.d.ts +19 -0
  20. package/dist/lib/feePdf.d.ts.map +1 -0
  21. package/dist/lib/feePdf.js +167 -0
  22. package/dist/lib/feePdf.js.map +1 -0
  23. package/dist/lib/feePdfFormat.d.ts +4 -0
  24. package/dist/lib/feePdfFormat.d.ts.map +1 -0
  25. package/dist/lib/feePdfFormat.js +16 -0
  26. package/dist/lib/feePdfFormat.js.map +1 -0
  27. package/dist/lib/idCardPdf.d.ts +26 -0
  28. package/dist/lib/idCardPdf.d.ts.map +1 -0
  29. package/dist/lib/idCardPdf.js +272 -0
  30. package/dist/lib/idCardPdf.js.map +1 -0
  31. package/dist/lib/pdfLetterhead.d.ts +41 -0
  32. package/dist/lib/pdfLetterhead.d.ts.map +1 -0
  33. package/dist/lib/pdfLetterhead.js +86 -0
  34. package/dist/lib/pdfLetterhead.js.map +1 -0
  35. package/dist/lib/transcriptPdf.d.ts +55 -0
  36. package/dist/lib/transcriptPdf.d.ts.map +1 -0
  37. package/dist/lib/transcriptPdf.js +303 -0
  38. package/dist/lib/transcriptPdf.js.map +1 -0
  39. package/dist/models/academicCalendar.d.ts +198 -0
  40. package/dist/models/academicCalendar.d.ts.map +1 -0
  41. package/dist/models/academicCalendar.js +106 -0
  42. package/dist/models/academicCalendar.js.map +1 -0
  43. package/dist/models/agenda.d.ts +8 -0
  44. package/dist/models/agenda.d.ts.map +1 -1
  45. package/dist/models/announcement.d.ts +2 -0
  46. package/dist/models/announcement.d.ts.map +1 -1
  47. package/dist/models/assignment.d.ts +66 -0
  48. package/dist/models/assignment.d.ts.map +1 -1
  49. package/dist/models/attendance.d.ts +659 -21
  50. package/dist/models/attendance.d.ts.map +1 -1
  51. package/dist/models/attendance.js +544 -75
  52. package/dist/models/attendance.js.map +1 -1
  53. package/dist/models/auth.d.ts +2 -0
  54. package/dist/models/auth.d.ts.map +1 -1
  55. package/dist/models/class.d.ts +12 -0
  56. package/dist/models/class.d.ts.map +1 -1
  57. package/dist/models/class.js +3 -2
  58. package/dist/models/class.js.map +1 -1
  59. package/dist/models/event.d.ts +16 -0
  60. package/dist/models/event.d.ts.map +1 -1
  61. package/dist/models/fees.d.ts +554 -0
  62. package/dist/models/fees.d.ts.map +1 -0
  63. package/dist/models/fees.js +278 -0
  64. package/dist/models/fees.js.map +1 -0
  65. package/dist/models/file.d.ts +29 -0
  66. package/dist/models/file.d.ts.map +1 -1
  67. package/dist/models/folder.d.ts +2 -0
  68. package/dist/models/folder.d.ts.map +1 -1
  69. package/dist/models/labChat.d.ts +2 -0
  70. package/dist/models/labChat.d.ts.map +1 -1
  71. package/dist/models/marketplace.d.ts +178 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +115 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +304 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +78 -0
  78. package/dist/models/moduleItem.js.map +1 -0
  79. package/dist/models/newtonChat.d.ts +1 -0
  80. package/dist/models/newtonChat.d.ts.map +1 -1
  81. package/dist/models/school.d.ts +193 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +80 -2
  84. package/dist/models/school.js.map +1 -1
  85. package/dist/models/section.d.ts +4 -0
  86. package/dist/models/section.d.ts.map +1 -1
  87. package/dist/models/worksheet.d.ts +19 -0
  88. package/dist/models/worksheet.d.ts.map +1 -1
  89. package/dist/models/worksheet.js +2 -2
  90. package/dist/models/worksheet.js.map +1 -1
  91. package/dist/routers/_app.d.ts +11605 -7133
  92. package/dist/routers/_app.d.ts.map +1 -1
  93. package/dist/routers/_app.js +12 -2
  94. package/dist/routers/_app.js.map +1 -1
  95. package/dist/routers/academicCalendar.d.ts +229 -0
  96. package/dist/routers/academicCalendar.d.ts.map +1 -0
  97. package/dist/routers/academicCalendar.js +75 -0
  98. package/dist/routers/academicCalendar.js.map +1 -0
  99. package/dist/routers/admin.d.ts +2 -0
  100. package/dist/routers/admin.d.ts.map +1 -1
  101. package/dist/routers/agenda.d.ts +8 -0
  102. package/dist/routers/agenda.d.ts.map +1 -1
  103. package/dist/routers/assignment.d.ts +75 -0
  104. package/dist/routers/assignment.d.ts.map +1 -1
  105. package/dist/routers/assignment.js +10 -2
  106. package/dist/routers/assignment.js.map +1 -1
  107. package/dist/routers/attendance.d.ts +103 -0
  108. package/dist/routers/attendance.d.ts.map +1 -1
  109. package/dist/routers/attendance.js +8 -5
  110. package/dist/routers/attendance.js.map +1 -1
  111. package/dist/routers/class.d.ts +27 -0
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/class.js +6 -2
  114. package/dist/routers/class.js.map +1 -1
  115. package/dist/routers/event.d.ts +8 -0
  116. package/dist/routers/event.d.ts.map +1 -1
  117. package/dist/routers/fees.d.ts +513 -0
  118. package/dist/routers/fees.d.ts.map +1 -0
  119. package/dist/routers/fees.js +88 -0
  120. package/dist/routers/fees.js.map +1 -0
  121. package/dist/routers/file.d.ts +2 -0
  122. package/dist/routers/file.d.ts.map +1 -1
  123. package/dist/routers/gradebook.d.ts +4 -1
  124. package/dist/routers/gradebook.d.ts.map +1 -1
  125. package/dist/routers/marketplace.d.ts +371 -0
  126. package/dist/routers/marketplace.d.ts.map +1 -0
  127. package/dist/routers/marketplace.js +87 -0
  128. package/dist/routers/marketplace.js.map +1 -0
  129. package/dist/routers/moduleItem.d.ts +263 -0
  130. package/dist/routers/moduleItem.d.ts.map +1 -0
  131. package/dist/routers/moduleItem.js +60 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +169 -0
  134. package/dist/routers/parent.d.ts.map +1 -0
  135. package/dist/routers/parent.js +49 -0
  136. package/dist/routers/parent.js.map +1 -0
  137. package/dist/routers/school.d.ts +476 -6
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +130 -4
  140. package/dist/routers/school.js.map +1 -1
  141. package/dist/routers/worksheet.d.ts +27 -0
  142. package/dist/routers/worksheet.d.ts.map +1 -1
  143. package/dist/routers/worksheet.js +10 -3
  144. package/dist/routers/worksheet.js.map +1 -1
  145. package/dist/services/academicCalendar.d.ts +173 -0
  146. package/dist/services/academicCalendar.d.ts.map +1 -0
  147. package/dist/services/academicCalendar.js +214 -0
  148. package/dist/services/academicCalendar.js.map +1 -0
  149. package/dist/services/adminInstitution.d.ts +2 -0
  150. package/dist/services/adminInstitution.d.ts.map +1 -1
  151. package/dist/services/agenda.d.ts +8 -0
  152. package/dist/services/agenda.d.ts.map +1 -1
  153. package/dist/services/assignment.d.ts +75 -0
  154. package/dist/services/assignment.d.ts.map +1 -1
  155. package/dist/services/assignment.js +91 -12
  156. package/dist/services/assignment.js.map +1 -1
  157. package/dist/services/attendance.d.ts +323 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +385 -25
  160. package/dist/services/attendance.js.map +1 -1
  161. package/dist/services/attendanceMerge.d.ts +53 -0
  162. package/dist/services/attendanceMerge.d.ts.map +1 -0
  163. package/dist/services/attendanceMerge.js +87 -0
  164. package/dist/services/attendanceMerge.js.map +1 -0
  165. package/dist/services/attendanceRate.d.ts +24 -0
  166. package/dist/services/attendanceRate.d.ts.map +1 -0
  167. package/dist/services/attendanceRate.js +31 -0
  168. package/dist/services/attendanceRate.js.map +1 -0
  169. package/dist/services/attendanceStats.d.ts +22 -0
  170. package/dist/services/attendanceStats.d.ts.map +1 -0
  171. package/dist/services/attendanceStats.js +45 -0
  172. package/dist/services/attendanceStats.js.map +1 -0
  173. package/dist/services/class.d.ts +27 -0
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +26 -3
  176. package/dist/services/class.js.map +1 -1
  177. package/dist/services/event.d.ts +8 -0
  178. package/dist/services/event.d.ts.map +1 -1
  179. package/dist/services/fees.d.ts +422 -0
  180. package/dist/services/fees.d.ts.map +1 -0
  181. package/dist/services/fees.js +310 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +49 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +52 -0
  186. package/dist/services/feesAuthz.js.map +1 -0
  187. package/dist/services/file.d.ts +4 -0
  188. package/dist/services/file.d.ts.map +1 -1
  189. package/dist/services/gradebook.d.ts +18 -1
  190. package/dist/services/gradebook.d.ts.map +1 -1
  191. package/dist/services/gradebook.js +57 -2
  192. package/dist/services/gradebook.js.map +1 -1
  193. package/dist/services/idCard.d.ts +11 -0
  194. package/dist/services/idCard.d.ts.map +1 -0
  195. package/dist/services/idCard.js +122 -0
  196. package/dist/services/idCard.js.map +1 -0
  197. package/dist/services/marketplace.d.ts +301 -0
  198. package/dist/services/marketplace.d.ts.map +1 -0
  199. package/dist/services/marketplace.js +628 -0
  200. package/dist/services/marketplace.js.map +1 -0
  201. package/dist/services/moduleItem.d.ts +196 -0
  202. package/dist/services/moduleItem.d.ts.map +1 -0
  203. package/dist/services/moduleItem.js +188 -0
  204. package/dist/services/moduleItem.js.map +1 -0
  205. package/dist/services/notification.d.ts +58 -0
  206. package/dist/services/notification.d.ts.map +1 -1
  207. package/dist/services/notification.js +30 -2
  208. package/dist/services/notification.js.map +1 -1
  209. package/dist/services/parentPortal.d.ts +133 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +328 -0
  212. package/dist/services/parentPortal.js.map +1 -0
  213. package/dist/services/school.d.ts +122 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +179 -47
  216. package/dist/services/school.js.map +1 -1
  217. package/dist/services/studentImport.d.ts +34 -0
  218. package/dist/services/studentImport.d.ts.map +1 -0
  219. package/dist/services/studentImport.js +236 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +61 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +243 -0
  224. package/dist/services/transcript.js.map +1 -0
  225. package/dist/services/worksheet.d.ts +22 -0
  226. package/dist/services/worksheet.d.ts.map +1 -1
  227. package/dist/services/worksheet.js +94 -2
  228. package/dist/services/worksheet.js.map +1 -1
  229. package/dist/trpc.d.ts +1 -1
  230. package/dist/trpc.d.ts.map +1 -1
  231. package/dist/trpc.js +5 -5
  232. package/dist/trpc.js.map +1 -1
  233. package/dist/utils/adminPanelAccess.d.ts +4 -5
  234. package/dist/utils/adminPanelAccess.d.ts.map +1 -1
  235. package/dist/utils/adminPanelAccess.js +12 -34
  236. package/dist/utils/adminPanelAccess.js.map +1 -1
  237. package/package.json +2 -1
  238. package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
  239. package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
  240. package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
  241. package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
  242. package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
  243. package/prisma/schema.prisma +371 -11
  244. package/scripts/import-nepal-cdc-curriculum.ts +379 -0
  245. package/src/lib/assignmentTypeDefaults.ts +222 -0
  246. package/src/lib/config/env.ts +0 -3
  247. package/src/lib/feePdf.ts +199 -0
  248. package/src/lib/feePdfFormat.ts +14 -0
  249. package/src/lib/idCardPdf.ts +336 -0
  250. package/src/lib/pdfLetterhead.ts +124 -0
  251. package/src/lib/transcriptPdf.ts +502 -0
  252. package/src/models/academicCalendar.ts +139 -0
  253. package/src/models/attendance.ts +704 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +158 -0
  257. package/src/models/moduleItem.ts +107 -0
  258. package/src/models/school.ts +95 -0
  259. package/src/models/worksheet.ts +2 -0
  260. package/src/routers/_app.ts +10 -0
  261. package/src/routers/academicCalendar.ts +103 -0
  262. package/src/routers/assignment.ts +8 -0
  263. package/src/routers/attendance.ts +7 -3
  264. package/src/routers/class.ts +4 -0
  265. package/src/routers/fees.ts +126 -0
  266. package/src/routers/marketplace.ts +134 -0
  267. package/src/routers/moduleItem.ts +89 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +184 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +299 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +548 -26
  274. package/src/services/attendanceMerge.ts +139 -0
  275. package/src/services/attendanceRate.ts +41 -0
  276. package/src/services/attendanceStats.ts +63 -0
  277. package/src/services/class.ts +25 -1
  278. package/src/services/fees.ts +424 -0
  279. package/src/services/feesAuthz.ts +77 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +785 -0
  283. package/src/services/moduleItem.ts +274 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +380 -0
  286. package/src/services/school.ts +243 -45
  287. package/src/services/studentImport.ts +273 -0
  288. package/src/services/transcript.ts +313 -0
  289. package/src/services/worksheet.ts +111 -0
  290. package/src/trpc.ts +3 -3
  291. package/src/utils/adminPanelAccess.ts +12 -35
  292. package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
  293. package/tests/unit/lib/feePdf.test.ts +15 -0
  294. package/tests/unit/lib/idCardPdf.test.ts +40 -0
  295. package/tests/unit/lib/transcriptPdf.test.ts +111 -0
  296. package/tests/unit/services/attendanceMerge.test.ts +90 -0
  297. package/tests/unit/services/attendanceRate.test.ts +38 -0
  298. package/tests/unit/services/attendanceStats.test.ts +54 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * School-admin branded transcripts / progress reports (A4 — Nepal pilot).
3
+ *
4
+ * Aggregates per-class gradebook grades + grading boundaries + attendance %
5
+ * into a pdf-lib PDF. Branding: school name + logo (letterhead optional when
6
+ * present on School; schema does not include it yet — do not block).
7
+ *
8
+ * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or
9
+ * a free-text `schoolYear` string for the report header.
10
+ *
11
+ * Attendance is read via the existing present/late/absent M2M lists only
12
+ * (same shaping as getStudentTimeline). Do not conflict with A1 Attendance
13
+ * refactor — rate math lives in attendanceRate.ts.
14
+ */
15
+
16
+ import { TRPCError } from "@trpc/server";
17
+ import { prisma } from "../lib/prisma.js";
18
+ import { bucket } from "../lib/googleCloudStorage.js";
19
+ import {
20
+ buildTranscriptPdf,
21
+ isLevelBasedBoundary,
22
+ type TranscriptClassRow,
23
+ type TranscriptReportVariant,
24
+ } from "../lib/transcriptPdf.js";
25
+ import {
26
+ findActiveSchoolAdminMembership,
27
+ getSchoolById,
28
+ } from "../models/school.js";
29
+ import { computeAttendanceRate } from "./attendanceRate.js";
30
+ import { computeStudentGradeInClass } from "./gradebook.js";
31
+
32
+ const MAX_BULK = 50;
33
+
34
+ export interface GenerateTranscriptInput {
35
+ schoolId: string;
36
+ studentId: string;
37
+ from?: Date;
38
+ to?: Date;
39
+ /**
40
+ * Free-text school year label (e.g. "2082 BS", "2025–2026").
41
+ * Prefer AcademicTerm (C1) once available.
42
+ */
43
+ schoolYear?: string;
44
+ }
45
+
46
+ async function requireSchoolAdmin(schoolId: string, userId: string) {
47
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
48
+ if (!membership) {
49
+ throw new TRPCError({
50
+ code: "FORBIDDEN",
51
+ message: "Only school admins can generate transcripts",
52
+ });
53
+ }
54
+ return membership;
55
+ }
56
+
57
+ async function loadFileBytes(
58
+ path: string | null | undefined
59
+ ): Promise<Uint8Array | null> {
60
+ if (!path) return null;
61
+ try {
62
+ const [bytes] = await bucket.file(path).download();
63
+ return new Uint8Array(bytes);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function formatPeriodLabel(from?: Date, to?: Date): string | null {
70
+ if (!from && !to) return null;
71
+ const fmt = (d: Date) => d.toISOString().slice(0, 10);
72
+ if (from && to) return `${fmt(from)} – ${fmt(to)}`;
73
+ if (from) return `From ${fmt(from)}`;
74
+ return `Through ${fmt(to!)}`;
75
+ }
76
+
77
+ function slugify(value: string): string {
78
+ return value
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9]+/g, "-")
81
+ .replace(/^-|-$/g, "")
82
+ .slice(0, 48);
83
+ }
84
+
85
+ async function fetchStudentAttendance(
86
+ studentId: string,
87
+ from?: Date,
88
+ to?: Date
89
+ ) {
90
+ const range = {
91
+ ...(from ? { gte: from } : {}),
92
+ ...(to ? { lte: to } : {}),
93
+ };
94
+ const hasRange = Object.keys(range).length > 0;
95
+
96
+ // Same M2M present/late/absent read path as getStudentTimeline (A1-safe).
97
+ const attendance = await prisma.attendance.findMany({
98
+ where: {
99
+ OR: [
100
+ { present: { some: { id: studentId } } },
101
+ { late: { some: { id: studentId } } },
102
+ { absent: { some: { id: studentId } } },
103
+ ],
104
+ ...(hasRange ? { date: range } : {}),
105
+ },
106
+ select: {
107
+ id: true,
108
+ present: { where: { id: studentId }, select: { id: true } },
109
+ late: { where: { id: studentId }, select: { id: true } },
110
+ absent: { where: { id: studentId }, select: { id: true } },
111
+ },
112
+ });
113
+
114
+ return attendance.map((record) => ({
115
+ status: record.present.length
116
+ ? ("PRESENT" as const)
117
+ : record.late.length
118
+ ? ("LATE" as const)
119
+ : ("ABSENT" as const),
120
+ }));
121
+ }
122
+
123
+ async function buildClassRows(
124
+ schoolId: string,
125
+ studentId: string
126
+ ): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {
127
+ const classes = await prisma.class.findMany({
128
+ where: {
129
+ schoolId,
130
+ students: { some: { id: studentId } },
131
+ },
132
+ select: {
133
+ id: true,
134
+ name: true,
135
+ subject: true,
136
+ section: true,
137
+ },
138
+ orderBy: [{ subject: "asc" }, { name: "asc" }],
139
+ });
140
+
141
+ const rows: TranscriptClassRow[] = [];
142
+ let levelVotes = 0;
143
+ let letterVotes = 0;
144
+
145
+ for (const cls of classes) {
146
+ const grade = await computeStudentGradeInClass(cls.id, studentId);
147
+ if (!grade) continue;
148
+
149
+ if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {
150
+ levelVotes += 1;
151
+ } else if (grade.gradingBoundary) {
152
+ letterVotes += 1;
153
+ }
154
+
155
+ rows.push({
156
+ className: cls.name,
157
+ subject: cls.subject,
158
+ section: cls.section,
159
+ percentage: grade.result.percentage,
160
+ gradeLabel: grade.letterGrade,
161
+ categorySummaries: grade.result.categories.map((c) => ({
162
+ name: c.name,
163
+ average: c.average,
164
+ })),
165
+ });
166
+ }
167
+
168
+ const variant: TranscriptReportVariant =
169
+ levelVotes > 0 && levelVotes >= letterVotes ? "level" : "letter";
170
+
171
+ return { rows, variant };
172
+ }
173
+
174
+ export async function generateTranscript(
175
+ userId: string,
176
+ input: GenerateTranscriptInput
177
+ ) {
178
+ await requireSchoolAdmin(input.schoolId, userId);
179
+
180
+ const school = await getSchoolById(input.schoolId);
181
+ if (!school) {
182
+ throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
183
+ }
184
+
185
+ const membership = await prisma.schoolMembership.findFirst({
186
+ where: {
187
+ schoolId: input.schoolId,
188
+ userId: input.studentId,
189
+ status: "ACTIVE",
190
+ role: "STUDENT",
191
+ },
192
+ select: {
193
+ user: {
194
+ select: {
195
+ id: true,
196
+ username: true,
197
+ profile: { select: { displayName: true } },
198
+ },
199
+ },
200
+ },
201
+ });
202
+ if (!membership) {
203
+ throw new TRPCError({
204
+ code: "NOT_FOUND",
205
+ message: "Student is not an active student in this school",
206
+ });
207
+ }
208
+
209
+ const studentName =
210
+ membership.user.profile?.displayName || membership.user.username;
211
+
212
+ const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([
213
+ buildClassRows(input.schoolId, input.studentId),
214
+ fetchStudentAttendance(input.studentId, input.from, input.to),
215
+ loadFileBytes(school.logo?.path),
216
+ ]);
217
+
218
+ // Soft letterhead support when schema later adds it — never required.
219
+ const schoolAny = school as {
220
+ letterhead?: { path?: string | null; type?: string | null } | null;
221
+ letterheadPath?: string | null;
222
+ letterheadType?: string | null;
223
+ };
224
+ const letterheadPath =
225
+ schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;
226
+ const letterheadType =
227
+ schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;
228
+ const letterheadBytes = await loadFileBytes(letterheadPath);
229
+
230
+ const attendance = computeAttendanceRate(attendanceStatuses);
231
+ const generatedAt = new Date();
232
+
233
+ const pdfBytes = await buildTranscriptPdf({
234
+ schoolName: school.name,
235
+ studentName,
236
+ studentUsername: membership.user.username,
237
+ schoolYear: input.schoolYear ?? null,
238
+ periodLabel: formatPeriodLabel(input.from, input.to),
239
+ variant,
240
+ classes: rows,
241
+ attendance,
242
+ logoBytes,
243
+ logoContentType: school.logo?.type ?? null,
244
+ letterheadBytes,
245
+ letterheadContentType: letterheadType,
246
+ generatedAt,
247
+ });
248
+
249
+ const yearSlug = input.schoolYear
250
+ ? slugify(input.schoolYear)
251
+ : generatedAt.toISOString().slice(0, 10);
252
+ const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;
253
+
254
+ return {
255
+ pdfBase64: Buffer.from(pdfBytes).toString("base64"),
256
+ fileName,
257
+ variant,
258
+ studentId: input.studentId,
259
+ studentName,
260
+ /** C1 blocker: AcademicTerm model does not exist yet. */
261
+ academicTermSupported: false as const,
262
+ attendance,
263
+ classCount: rows.length,
264
+ };
265
+ }
266
+
267
+ export async function generateTranscripts(
268
+ userId: string,
269
+ input: {
270
+ schoolId: string;
271
+ studentIds: string[];
272
+ from?: Date;
273
+ to?: Date;
274
+ schoolYear?: string;
275
+ }
276
+ ) {
277
+ await requireSchoolAdmin(input.schoolId, userId);
278
+
279
+ const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);
280
+ if (uniqueIds.length === 0) {
281
+ throw new TRPCError({
282
+ code: "BAD_REQUEST",
283
+ message: "Select at least one student",
284
+ });
285
+ }
286
+
287
+ type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;
288
+ const results: TranscriptResult[] = [];
289
+ const errors: { studentId: string; message: string }[] = [];
290
+
291
+ for (const studentId of uniqueIds) {
292
+ try {
293
+ const result = await generateTranscript(userId, {
294
+ schoolId: input.schoolId,
295
+ studentId,
296
+ from: input.from,
297
+ to: input.to,
298
+ schoolYear: input.schoolYear,
299
+ });
300
+ results.push(result);
301
+ } catch (error) {
302
+ const message =
303
+ error instanceof TRPCError
304
+ ? error.message
305
+ : error instanceof Error
306
+ ? error.message
307
+ : "Failed to generate transcript";
308
+ errors.push({ studentId, message });
309
+ }
310
+ }
311
+
312
+ return { transcripts: results, errors };
313
+ }
@@ -308,6 +308,7 @@ export async function addQuestionToWorksheet(
308
308
  markScheme?: object;
309
309
  type: WorksheetQuestionType;
310
310
  order?: number;
311
+ parentQuestionId?: string | null;
311
312
  }
312
313
  ) {
313
314
  const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
@@ -317,6 +318,24 @@ export async function addQuestionToWorksheet(
317
318
  message: "Only class teachers can modify worksheet questions",
318
319
  });
319
320
  }
321
+ if (data.parentQuestionId) {
322
+ const parent = await findWorksheetQuestionByIdInWorksheet(
323
+ data.parentQuestionId,
324
+ worksheetId
325
+ );
326
+ if (!parent) {
327
+ throw new TRPCError({
328
+ code: "BAD_REQUEST",
329
+ message: "Parent question not found in this worksheet",
330
+ });
331
+ }
332
+ if (parent.parentQuestionId) {
333
+ throw new TRPCError({
334
+ code: "BAD_REQUEST",
335
+ message: "Subquestions cannot themselves have children",
336
+ });
337
+ }
338
+ }
320
339
  return createWorksheetQuestion({
321
340
  worksheetId,
322
341
  ...data,
@@ -459,6 +478,7 @@ export async function updateWorksheetQuestionRecord(
459
478
  options?: object;
460
479
  markScheme?: object;
461
480
  type?: WorksheetQuestionType;
481
+ parentQuestionId?: string | null;
462
482
  }
463
483
  ) {
464
484
  const question = await findWorksheetQuestionByIdForTeacher(questionId, userId);
@@ -468,9 +488,100 @@ export async function updateWorksheetQuestionRecord(
468
488
  message: "Only class teachers can update worksheet questions",
469
489
  });
470
490
  }
491
+ if (data.parentQuestionId) {
492
+ if (data.parentQuestionId === questionId) {
493
+ throw new TRPCError({
494
+ code: "BAD_REQUEST",
495
+ message: "A question cannot be its own parent",
496
+ });
497
+ }
498
+ const parent = await findWorksheetQuestionByIdInWorksheet(
499
+ data.parentQuestionId,
500
+ worksheetId
501
+ );
502
+ if (!parent) {
503
+ throw new TRPCError({
504
+ code: "BAD_REQUEST",
505
+ message: "Parent question not found in this worksheet",
506
+ });
507
+ }
508
+ if (parent.parentQuestionId) {
509
+ throw new TRPCError({
510
+ code: "BAD_REQUEST",
511
+ message: "Subquestions cannot themselves have children",
512
+ });
513
+ }
514
+ }
471
515
  return updateWorksheetQuestion(questionId, data);
472
516
  }
473
517
 
518
+ /** Answering statistics for each question — shown in the teacher worksheet editor. */
519
+ export async function getWorksheetQuestionStats(
520
+ worksheetId: string,
521
+ userId: string
522
+ ) {
523
+ const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
524
+ if (!worksheet) {
525
+ throw new TRPCError({
526
+ code: "FORBIDDEN",
527
+ message: "Only class teachers can view worksheet answering statistics",
528
+ });
529
+ }
530
+
531
+ const questions = await prisma.worksheetQuestion.findMany({
532
+ where: { worksheetId },
533
+ select: {
534
+ id: true,
535
+ parentQuestionId: true,
536
+ order: true,
537
+ points: true,
538
+ question: true,
539
+ type: true,
540
+ studentResponses: {
541
+ select: {
542
+ id: true,
543
+ isCorrect: true,
544
+ points: true,
545
+ response: true,
546
+ studentWorksheetResponse: {
547
+ select: { submitted: true },
548
+ },
549
+ },
550
+ },
551
+ },
552
+ orderBy: { order: "asc" },
553
+ });
554
+
555
+ return questions.map((q) => {
556
+ const responses = q.studentResponses;
557
+ const answered = responses.filter(
558
+ (r) => r.response !== null && r.response !== ""
559
+ );
560
+ const submitted = responses.filter(
561
+ (r) => r.studentWorksheetResponse?.submitted
562
+ );
563
+ const correct = answered.filter((r) => r.isCorrect).length;
564
+ return {
565
+ questionId: q.id,
566
+ parentQuestionId: q.parentQuestionId,
567
+ order: q.order,
568
+ type: q.type,
569
+ points: q.points,
570
+ promptPreview: q.question.slice(0, 120),
571
+ totalResponses: responses.length,
572
+ answeredCount: answered.length,
573
+ submittedCount: submitted.length,
574
+ correctCount: correct,
575
+ averagePoints:
576
+ answered.length === 0
577
+ ? null
578
+ : answered.reduce((sum, r) => sum + (r.points ?? 0), 0) /
579
+ answered.length,
580
+ correctRate: answered.length === 0 ? null : correct / answered.length,
581
+ };
582
+ });
583
+ }
584
+
474
585
  export async function deleteWorksheetQuestionRecord(
475
586
  worksheetId: string,
476
587
  questionId: string,
package/src/trpc.ts CHANGED
@@ -7,7 +7,7 @@ import { Request, Response } from 'express';
7
7
  import { z } from 'zod';
8
8
  import { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';
9
9
  import { findUserBySessionToken } from './models/auth.js';
10
- import { assertAdminPanelUser } from './utils/adminPanelAccess.js';
10
+ import { assertAdminPanelPassword } from './utils/adminPanelAccess.js';
11
11
 
12
12
  interface CreateContextOptions {
13
13
  req: Request;
@@ -96,11 +96,11 @@ const adminPanelMiddleware = t.middleware(({ ctx, next }) => {
96
96
  message: 'Not authenticated',
97
97
  });
98
98
  }
99
- assertAdminPanelUser(ctx.user.id);
99
+ assertAdminPanelPassword(ctx.req);
100
100
  return next({ ctx });
101
101
  });
102
102
 
103
- /** Authenticated + env allowlist (see `assertAdminPanelUser`). */
103
+ /** Authenticated + fixed admin password header (see `assertAdminPanelPassword`). */
104
104
  export const adminProcedure = protectedProcedure.use(adminPanelMiddleware);
105
105
 
106
106
  export const protectedClassMemberProcedure = protectedProcedure
@@ -1,46 +1,23 @@
1
1
  import { TRPCError } from '@trpc/server';
2
- import { env } from '../lib/config/env.js';
2
+ import type { Request } from 'express';
3
3
 
4
- function parseAdminUserIds(raw: string | undefined): Set<string> {
5
- if (!raw?.trim()) return new Set();
6
- return new Set(
7
- raw
8
- .split(/[\s,]+/)
9
- .map((s) => s.trim())
10
- .filter(Boolean),
11
- );
12
- }
4
+ /** Fixed password gating the admin tRPC routes. */
5
+ const ADMIN_PANEL_PASSWORD = 'alan6767';
6
+
7
+ const ADMIN_PASSWORD_HEADER = 'x-admin-password';
13
8
 
14
9
  /**
15
- * Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty
16
- * comma/whitespace-separated list of user UUIDs. In non-production, an empty
17
- * unset value allows any authenticated user (local ops); a non-empty list is
18
- * still enforced.
10
+ * Admin tRPC routes require the fixed admin password in the
11
+ * `x-admin-password` header (sent by the admin panel).
19
12
  */
20
- export function assertAdminPanelUser(userId: string): void {
21
- const allowed = parseAdminUserIds(env.ADMIN_PANEL_USER_IDS);
22
-
23
- if (env.NODE_ENV === 'production') {
24
- if (allowed.size === 0) {
25
- throw new TRPCError({
26
- code: 'FORBIDDEN',
27
- message:
28
- 'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',
29
- });
30
- }
31
- if (!allowed.has(userId)) {
32
- throw new TRPCError({
33
- code: 'FORBIDDEN',
34
- message: 'You are not allowed to use the admin API.',
35
- });
36
- }
37
- return;
38
- }
13
+ export function assertAdminPanelPassword(req: Request): void {
14
+ const header = req.headers[ADMIN_PASSWORD_HEADER];
15
+ const provided = typeof header === 'string' ? header : header?.[0];
39
16
 
40
- if (allowed.size > 0 && !allowed.has(userId)) {
17
+ if (provided !== ADMIN_PANEL_PASSWORD) {
41
18
  throw new TRPCError({
42
19
  code: 'FORBIDDEN',
43
- message: 'You are not allowed to use the admin API.',
20
+ message: 'Invalid admin password.',
44
21
  });
45
22
  }
46
23
  }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getAssignmentTypeDefaults,
4
+ resolveAssignmentBehavior,
5
+ } from "../../src/lib/assignmentTypeDefaults.js";
6
+
7
+ describe("getAssignmentTypeDefaults", () => {
8
+ it("configures QUIZ without late file spam by default", () => {
9
+ const quiz = getAssignmentTypeDefaults("QUIZ");
10
+ expect(quiz.acceptFiles).toBe(false);
11
+ expect(quiz.singleAttempt).toBe(true);
12
+ expect(quiz.allowLateSubmissions).toBe(false);
13
+ expect(quiz.acceptWorksheet).toBe(true);
14
+ });
15
+
16
+ it("configures ESSAY for extended response", () => {
17
+ const essay = getAssignmentTypeDefaults("ESSAY");
18
+ expect(essay.acceptExtendedResponse).toBe(true);
19
+ expect(essay.acceptFiles).toBe(false);
20
+ expect(essay.singleAttempt).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("resolveAssignmentBehavior", () => {
25
+ it("applies QUIZ defaults when fields omitted", () => {
26
+ const resolved = resolveAssignmentBehavior({ type: "QUIZ" });
27
+ expect(resolved.acceptFiles).toBe(false);
28
+ expect(resolved.singleAttempt).toBe(true);
29
+ expect(resolved.allowLateSubmissions).toBe(false);
30
+ });
31
+
32
+ it("lets teachers override QUIZ file acceptance", () => {
33
+ const resolved = resolveAssignmentBehavior({
34
+ type: "QUIZ",
35
+ acceptFiles: true,
36
+ acceptWorksheet: true,
37
+ });
38
+ expect(resolved.acceptFiles).toBe(true);
39
+ });
40
+
41
+ it("rejects zero deliverables", () => {
42
+ expect(() =>
43
+ resolveAssignmentBehavior({
44
+ type: "HOMEWORK",
45
+ acceptFiles: false,
46
+ acceptExtendedResponse: false,
47
+ acceptWorksheet: false,
48
+ applyTypeDefaults: false,
49
+ })
50
+ ).toThrow(/at least one deliverable/);
51
+ });
52
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatNpr, monthLabel } from "../../../src/lib/feePdfFormat.js";
3
+
4
+ describe("feePdfFormat helpers", () => {
5
+ it("formats NPR amounts", () => {
6
+ expect(formatNpr(1500)).toContain("1,500");
7
+ expect(formatNpr(1500)).toMatch(/^NPR /);
8
+ });
9
+
10
+ it("labels calendar months in UTC", () => {
11
+ expect(monthLabel(2026, 1)).toBe("January 2026");
12
+ expect(monthLabel(2026, 8)).toBe("August 2026");
13
+ expect(monthLabel(2026, 12)).toBe("December 2026");
14
+ });
15
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_BRAND_COLOR,
4
+ ID_CARD_HEIGHT,
5
+ ID_CARD_WIDTH,
6
+ parseHexColor,
7
+ toPngBytes,
8
+ } from "../../../src/lib/idCardPdf.js";
9
+
10
+ describe("idCardPdf helpers", () => {
11
+ it("exposes CR80 card dimensions", () => {
12
+ expect(ID_CARD_WIDTH).toBe(243);
13
+ expect(ID_CARD_HEIGHT).toBe(153);
14
+ });
15
+
16
+ it("parses valid hex colors", () => {
17
+ const color = parseHexColor("#3B82F6");
18
+ expect(color.red).toBeCloseTo(0x3b / 255, 5);
19
+ expect(color.green).toBeCloseTo(0x82 / 255, 5);
20
+ expect(color.blue).toBeCloseTo(0xf6 / 255, 5);
21
+ });
22
+
23
+ it("falls back for invalid or missing hex", () => {
24
+ const fallback = parseHexColor(DEFAULT_BRAND_COLOR);
25
+ expect(parseHexColor(null)).toEqual(fallback);
26
+ expect(parseHexColor("not-a-color")).toEqual(fallback);
27
+ expect(parseHexColor("#fff")).toEqual(fallback);
28
+ });
29
+
30
+ it("converts a minimal PNG through sharp", async () => {
31
+ const tinyPng = Buffer.from(
32
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
33
+ "base64"
34
+ );
35
+ const out = await toPngBytes(tinyPng);
36
+ expect(out).not.toBeNull();
37
+ expect(out!.byteLength).toBeGreaterThan(20);
38
+ expect(Buffer.from(out!.slice(0, 4)).toString("hex")).toBe("89504e47");
39
+ });
40
+ });