@studious-lms/server 1.23.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 +18 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +7 -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 +11341 -7097
  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 +363 -7
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +100 -3
  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 +28 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +65 -45
  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 +6 -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 +139 -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 +65 -44
  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,502 @@
1
+ /**
2
+ * Branded transcript / progress-report PDF builder (pdf-lib).
3
+ *
4
+ * Variants:
5
+ * - `letter` — percentage + letter-grade style transcript
6
+ * - `level` — CDC Annex-4 style progress report (Below Basic / Basic /
7
+ * Proficient / Advanced) when grading boundaries are level-based
8
+ *
9
+ * School branding uses name + optional logo. Letterhead is not in the School
10
+ * schema yet — when added, callers can pass `letterheadBytes` without blocking.
11
+ */
12
+
13
+ import { PDFDocument, PDFFont, PDFPage, PDFImage, rgb, StandardFonts } from "pdf-lib";
14
+ import fontkit from "@pdf-lib/fontkit";
15
+ import { readFileSync } from "fs";
16
+ import { join } from "path";
17
+
18
+ export type TranscriptReportVariant = "letter" | "level";
19
+
20
+ export interface TranscriptClassRow {
21
+ className: string;
22
+ subject: string;
23
+ section: string;
24
+ percentage: number | null;
25
+ gradeLabel: string | null;
26
+ categorySummaries: { name: string; average: number | null }[];
27
+ }
28
+
29
+ export interface TranscriptAttendanceSummary {
30
+ present: number;
31
+ late: number;
32
+ absent: number;
33
+ total: number;
34
+ /** (present + late) / total * 100, or null when total is 0. */
35
+ percent: number | null;
36
+ }
37
+
38
+ export interface TranscriptPdfInput {
39
+ schoolName: string;
40
+ studentName: string;
41
+ studentUsername?: string | null;
42
+ /** Free-text school year (no AcademicTerm model yet — see C1). */
43
+ schoolYear?: string | null;
44
+ periodLabel?: string | null;
45
+ variant: TranscriptReportVariant;
46
+ classes: TranscriptClassRow[];
47
+ attendance: TranscriptAttendanceSummary;
48
+ logoBytes?: Uint8Array | null;
49
+ logoContentType?: string | null;
50
+ /** Optional future letterhead image; ignored when null/undefined. */
51
+ letterheadBytes?: Uint8Array | null;
52
+ letterheadContentType?: string | null;
53
+ generatedAt?: Date;
54
+ }
55
+
56
+ const PAGE_WIDTH = 595.28; // A4
57
+ const PAGE_HEIGHT = 841.89;
58
+ const MARGIN = 48;
59
+ const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
60
+
61
+ const LEVEL_ORDER = [
62
+ "Below Basic",
63
+ "Basic",
64
+ "Proficient",
65
+ "Advanced",
66
+ ] as const;
67
+
68
+ const LEVEL_COLORS: Record<(typeof LEVEL_ORDER)[number], ReturnType<typeof rgb>> = {
69
+ "Below Basic": rgb(0.75, 0.22, 0.17),
70
+ Basic: rgb(0.85, 0.55, 0.13),
71
+ Proficient: rgb(0.2, 0.45, 0.7),
72
+ Advanced: rgb(0.13, 0.55, 0.35),
73
+ };
74
+
75
+ async function embedFonts(pdfDoc: PDFDocument): Promise<{
76
+ regular: PDFFont;
77
+ bold: PDFFont;
78
+ }> {
79
+ pdfDoc.registerFontkit(fontkit);
80
+ const fontDir = join(process.cwd(), "src", "lib");
81
+ try {
82
+ const regularBytes = readFileSync(join(fontDir, "NotoSans-Regular.ttf"));
83
+ const boldBytes = readFileSync(join(fontDir, "NotoSans-Bold.ttf"));
84
+ return {
85
+ regular: await pdfDoc.embedFont(regularBytes),
86
+ bold: await pdfDoc.embedFont(boldBytes),
87
+ };
88
+ } catch {
89
+ return {
90
+ regular: await pdfDoc.embedFont(StandardFonts.Helvetica),
91
+ bold: await pdfDoc.embedFont(StandardFonts.HelveticaBold),
92
+ };
93
+ }
94
+ }
95
+
96
+ async function embedImage(
97
+ pdfDoc: PDFDocument,
98
+ bytes: Uint8Array | null | undefined,
99
+ contentType?: string | null
100
+ ): Promise<PDFImage | null> {
101
+ if (!bytes || bytes.length === 0) return null;
102
+ const type = (contentType ?? "").toLowerCase();
103
+ try {
104
+ if (type.includes("png") || (!type && looksLikePng(bytes))) {
105
+ return await pdfDoc.embedPng(bytes);
106
+ }
107
+ return await pdfDoc.embedJpg(bytes);
108
+ } catch {
109
+ try {
110
+ return await pdfDoc.embedPng(bytes);
111
+ } catch {
112
+ try {
113
+ return await pdfDoc.embedJpg(bytes);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ function looksLikePng(bytes: Uint8Array): boolean {
122
+ return (
123
+ bytes.length >= 8 &&
124
+ bytes[0] === 0x89 &&
125
+ bytes[1] === 0x50 &&
126
+ bytes[2] === 0x4e &&
127
+ bytes[3] === 0x47
128
+ );
129
+ }
130
+
131
+ function formatPercent(value: number | null): string {
132
+ if (value === null || Number.isNaN(value)) return "—";
133
+ return `${value.toFixed(1)}%`;
134
+ }
135
+
136
+ function drawText(
137
+ page: PDFPage,
138
+ text: string,
139
+ x: number,
140
+ y: number,
141
+ font: PDFFont,
142
+ size: number,
143
+ color = rgb(0.12, 0.12, 0.14)
144
+ ) {
145
+ page.drawText(text, { x, y, size, font, color });
146
+ }
147
+
148
+ function truncateToWidth(
149
+ text: string,
150
+ font: PDFFont,
151
+ size: number,
152
+ maxWidth: number
153
+ ): string {
154
+ if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
155
+ let truncated = text;
156
+ while (truncated.length > 1) {
157
+ truncated = truncated.slice(0, -1);
158
+ if (font.widthOfTextAtSize(`${truncated}…`, size) <= maxWidth) {
159
+ return `${truncated}…`;
160
+ }
161
+ }
162
+ return "…";
163
+ }
164
+
165
+ /**
166
+ * Detects CDC Annex-4 style level boundaries from a GradingBoundary
167
+ * `structured` JSON payload.
168
+ */
169
+ export function isLevelBasedBoundary(structured: string | null | undefined): boolean {
170
+ if (!structured) return false;
171
+ try {
172
+ const parsed = JSON.parse(structured) as {
173
+ boundaries?: { grade?: string }[];
174
+ };
175
+ if (!Array.isArray(parsed.boundaries) || parsed.boundaries.length === 0) {
176
+ return false;
177
+ }
178
+ const grades = parsed.boundaries
179
+ .map((b) => (b.grade ?? "").trim().toLowerCase())
180
+ .filter(Boolean);
181
+ if (grades.length === 0) return false;
182
+
183
+ const levelSet = new Set(LEVEL_ORDER.map((g) => g.toLowerCase()));
184
+ const matches = grades.filter((g) => levelSet.has(g));
185
+ return matches.length >= Math.ceil(grades.length * 0.5);
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ export async function buildTranscriptPdf(
192
+ input: TranscriptPdfInput
193
+ ): Promise<Uint8Array> {
194
+ const pdfDoc = await PDFDocument.create();
195
+ const { regular, bold } = await embedFonts(pdfDoc);
196
+ const logo = await embedImage(pdfDoc, input.logoBytes, input.logoContentType);
197
+ const letterhead = await embedImage(
198
+ pdfDoc,
199
+ input.letterheadBytes,
200
+ input.letterheadContentType
201
+ );
202
+
203
+ let page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
204
+ let y = PAGE_HEIGHT - MARGIN;
205
+
206
+ if (letterhead) {
207
+ const lhHeight = 72;
208
+ const scale = Math.min(
209
+ CONTENT_WIDTH / letterhead.width,
210
+ lhHeight / letterhead.height
211
+ );
212
+ const w = letterhead.width * scale;
213
+ const h = letterhead.height * scale;
214
+ page.drawImage(letterhead, {
215
+ x: MARGIN + (CONTENT_WIDTH - w) / 2,
216
+ y: y - h,
217
+ width: w,
218
+ height: h,
219
+ });
220
+ y -= h + 16;
221
+ }
222
+
223
+ const logoSize = 48;
224
+ let textLeft = MARGIN;
225
+ if (logo) {
226
+ const scale = Math.min(logoSize / logo.width, logoSize / logo.height);
227
+ const w = logo.width * scale;
228
+ const h = logo.height * scale;
229
+ page.drawImage(logo, {
230
+ x: MARGIN,
231
+ y: y - h,
232
+ width: w,
233
+ height: h,
234
+ });
235
+ textLeft = MARGIN + w + 14;
236
+ }
237
+
238
+ const title =
239
+ input.variant === "level"
240
+ ? "Student Progress Report"
241
+ : "Academic Transcript";
242
+
243
+ drawText(page, input.schoolName, textLeft, y - 16, bold, 16);
244
+ drawText(page, title, textLeft, y - 34, regular, 11, rgb(0.35, 0.35, 0.4));
245
+ y -= logo || letterhead ? 64 : 48;
246
+
247
+ page.drawLine({
248
+ start: { x: MARGIN, y },
249
+ end: { x: PAGE_WIDTH - MARGIN, y },
250
+ thickness: 1,
251
+ color: rgb(0.82, 0.82, 0.85),
252
+ });
253
+ y -= 22;
254
+
255
+ drawText(page, "Student", MARGIN, y, regular, 9, rgb(0.45, 0.45, 0.5));
256
+ y -= 14;
257
+ drawText(page, input.studentName, MARGIN, y, bold, 13);
258
+ y -= 16;
259
+ if (input.studentUsername) {
260
+ drawText(
261
+ page,
262
+ `@${input.studentUsername}`,
263
+ MARGIN,
264
+ y,
265
+ regular,
266
+ 9,
267
+ rgb(0.4, 0.4, 0.45)
268
+ );
269
+ y -= 14;
270
+ }
271
+
272
+ const metaParts: string[] = [];
273
+ if (input.schoolYear?.trim()) {
274
+ metaParts.push(`School year: ${input.schoolYear.trim()}`);
275
+ }
276
+ if (input.periodLabel?.trim()) {
277
+ metaParts.push(`Period: ${input.periodLabel.trim()}`);
278
+ }
279
+ if (metaParts.length === 0) {
280
+ metaParts.push("Period: All recorded grades & attendance");
281
+ }
282
+ drawText(
283
+ page,
284
+ metaParts.join(" · "),
285
+ MARGIN,
286
+ y,
287
+ regular,
288
+ 9,
289
+ rgb(0.35, 0.35, 0.4)
290
+ );
291
+ y -= 24;
292
+
293
+ y = drawAttendanceBlock(page, y, input.attendance, regular, bold);
294
+
295
+ if (input.variant === "level") {
296
+ y = drawLevelLegend(page, y, regular, bold);
297
+ }
298
+
299
+ const reportTitle =
300
+ input.variant === "level" ? "Subject progress" : "Course grades";
301
+ drawText(page, reportTitle, MARGIN, y, bold, 11);
302
+ y -= 16;
303
+
304
+ y = drawTableHeader(page, y, input.variant, bold);
305
+
306
+ if (input.classes.length === 0) {
307
+ drawText(
308
+ page,
309
+ "No enrolled classes with grade data for this period.",
310
+ MARGIN,
311
+ y,
312
+ regular,
313
+ 9,
314
+ rgb(0.45, 0.45, 0.5)
315
+ );
316
+ y -= 18;
317
+ } else {
318
+ for (const row of input.classes) {
319
+ if (y < MARGIN + 80) {
320
+ page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
321
+ y = PAGE_HEIGHT - MARGIN;
322
+ y = drawTableHeader(page, y, input.variant, bold);
323
+ }
324
+ y = drawClassRow(page, y, row, input.variant, regular, bold);
325
+ }
326
+ }
327
+
328
+ const generated = input.generatedAt ?? new Date();
329
+ const footer = `Generated ${generated.toISOString().slice(0, 10)} · Studious LMS`;
330
+ drawText(
331
+ page,
332
+ footer,
333
+ MARGIN,
334
+ MARGIN - 8,
335
+ regular,
336
+ 8,
337
+ rgb(0.55, 0.55, 0.58)
338
+ );
339
+
340
+ drawText(
341
+ page,
342
+ "Note: Academic terms (C1) are not modelled yet; period filters use dates or a school-year label.",
343
+ MARGIN,
344
+ MARGIN - 20,
345
+ regular,
346
+ 7,
347
+ rgb(0.6, 0.6, 0.62)
348
+ );
349
+
350
+ return pdfDoc.save();
351
+ }
352
+
353
+ function drawAttendanceBlock(
354
+ page: PDFPage,
355
+ y: number,
356
+ attendance: TranscriptAttendanceSummary,
357
+ regular: PDFFont,
358
+ bold: PDFFont
359
+ ): number {
360
+ drawText(page, "Attendance", MARGIN, y, bold, 11);
361
+ y -= 14;
362
+
363
+ const line = [
364
+ `Present: ${attendance.present}`,
365
+ `Late: ${attendance.late}`,
366
+ `Absent: ${attendance.absent}`,
367
+ `Rate: ${formatPercent(attendance.percent)}`,
368
+ ].join(" · ");
369
+
370
+ drawText(page, line, MARGIN, y, regular, 9, rgb(0.25, 0.25, 0.3));
371
+ y -= 8;
372
+ drawText(
373
+ page,
374
+ "Rate = (present + late) / (present + late + absent). Excused status is not modelled in this report path.",
375
+ MARGIN,
376
+ y,
377
+ regular,
378
+ 7,
379
+ rgb(0.55, 0.55, 0.58)
380
+ );
381
+ return y - 20;
382
+ }
383
+
384
+ function drawLevelLegend(
385
+ page: PDFPage,
386
+ y: number,
387
+ regular: PDFFont,
388
+ bold: PDFFont
389
+ ): number {
390
+ drawText(page, "Achievement levels (CDC Annex-4 style)", MARGIN, y, bold, 10);
391
+ y -= 14;
392
+ let x = MARGIN;
393
+ for (const level of LEVEL_ORDER) {
394
+ const color = LEVEL_COLORS[level];
395
+ page.drawRectangle({
396
+ x,
397
+ y: y - 2,
398
+ width: 8,
399
+ height: 8,
400
+ color,
401
+ });
402
+ drawText(page, level, x + 12, y - 1, regular, 8, color);
403
+ x += level.length * 8 * 0.52 + 28;
404
+ }
405
+ return y - 22;
406
+ }
407
+
408
+ function drawTableHeader(
409
+ page: PDFPage,
410
+ y: number,
411
+ variant: TranscriptReportVariant,
412
+ bold: PDFFont
413
+ ): number {
414
+ const gradeCol = variant === "level" ? "Level" : "Grade";
415
+ page.drawRectangle({
416
+ x: MARGIN,
417
+ y: y - 4,
418
+ width: CONTENT_WIDTH,
419
+ height: 18,
420
+ color: rgb(0.94, 0.95, 0.97),
421
+ });
422
+ drawText(page, "Subject / Class", MARGIN + 6, y, bold, 8);
423
+ drawText(page, "Section", MARGIN + 250, y, bold, 8);
424
+ drawText(page, "Score", MARGIN + 320, y, bold, 8);
425
+ drawText(page, gradeCol, MARGIN + 390, y, bold, 8);
426
+ return y - 20;
427
+ }
428
+
429
+ function drawClassRow(
430
+ page: PDFPage,
431
+ y: number,
432
+ row: TranscriptClassRow,
433
+ variant: TranscriptReportVariant,
434
+ regular: PDFFont,
435
+ bold: PDFFont
436
+ ): number {
437
+ const label = truncateToWidth(
438
+ row.subject?.trim()
439
+ ? `${row.subject} — ${row.className}`
440
+ : row.className,
441
+ regular,
442
+ 9,
443
+ 230
444
+ );
445
+ drawText(page, label, MARGIN + 6, y, regular, 9);
446
+ drawText(
447
+ page,
448
+ truncateToWidth(row.section || "—", regular, 9, 60),
449
+ MARGIN + 250,
450
+ y,
451
+ regular,
452
+ 9
453
+ );
454
+ drawText(page, formatPercent(row.percentage), MARGIN + 320, y, regular, 9);
455
+
456
+ const grade = row.gradeLabel?.trim() || "—";
457
+ const levelKey = LEVEL_ORDER.find(
458
+ (l) => l.toLowerCase() === grade.toLowerCase()
459
+ );
460
+ const gradeColor =
461
+ variant === "level" && levelKey
462
+ ? LEVEL_COLORS[levelKey]
463
+ : rgb(0.12, 0.12, 0.14);
464
+ drawText(
465
+ page,
466
+ truncateToWidth(grade, bold, 9, 120),
467
+ MARGIN + 390,
468
+ y,
469
+ bold,
470
+ 9,
471
+ gradeColor
472
+ );
473
+
474
+ y -= 14;
475
+
476
+ if (row.categorySummaries.length > 0) {
477
+ const cats = row.categorySummaries
478
+ .filter((c) => c.average !== null)
479
+ .map((c) => `${c.name}: ${formatPercent(c.average)}`)
480
+ .join(" · ");
481
+ if (cats) {
482
+ drawText(
483
+ page,
484
+ truncateToWidth(cats, regular, 7, CONTENT_WIDTH - 12),
485
+ MARGIN + 6,
486
+ y,
487
+ regular,
488
+ 7,
489
+ rgb(0.45, 0.45, 0.5)
490
+ );
491
+ y -= 12;
492
+ }
493
+ }
494
+
495
+ page.drawLine({
496
+ start: { x: MARGIN, y: y + 4 },
497
+ end: { x: PAGE_WIDTH - MARGIN, y: y + 4 },
498
+ thickness: 0.5,
499
+ color: rgb(0.9, 0.9, 0.92),
500
+ });
501
+ return y - 6;
502
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Academic year / term model helpers (C1).
3
+ */
4
+ import { prisma } from "../lib/prisma.js";
5
+
6
+ export function listAcademicYears(schoolId: string) {
7
+ return prisma.academicYear.findMany({
8
+ where: { schoolId },
9
+ orderBy: [{ isCurrent: "desc" }, { startDate: "desc" }],
10
+ include: {
11
+ terms: { orderBy: [{ isCurrent: "desc" }, { startDate: "asc" }] },
12
+ },
13
+ });
14
+ }
15
+
16
+ export function getAcademicYearById(id: string, schoolId: string) {
17
+ return prisma.academicYear.findFirst({
18
+ where: { id, schoolId },
19
+ include: {
20
+ terms: { orderBy: [{ isCurrent: "desc" }, { startDate: "asc" }] },
21
+ },
22
+ });
23
+ }
24
+
25
+ export function createAcademicYear(data: {
26
+ schoolId: string;
27
+ name: string;
28
+ startDate: Date;
29
+ endDate: Date;
30
+ isCurrent?: boolean;
31
+ }) {
32
+ return prisma.$transaction(async (tx) => {
33
+ if (data.isCurrent) {
34
+ await tx.academicYear.updateMany({
35
+ where: { schoolId: data.schoolId, isCurrent: true },
36
+ data: { isCurrent: false },
37
+ });
38
+ }
39
+ return tx.academicYear.create({
40
+ data: {
41
+ schoolId: data.schoolId,
42
+ name: data.name,
43
+ startDate: data.startDate,
44
+ endDate: data.endDate,
45
+ isCurrent: data.isCurrent ?? false,
46
+ },
47
+ include: { terms: true },
48
+ });
49
+ });
50
+ }
51
+
52
+ export function updateAcademicYear(
53
+ id: string,
54
+ schoolId: string,
55
+ data: {
56
+ name?: string;
57
+ startDate?: Date;
58
+ endDate?: Date;
59
+ isCurrent?: boolean;
60
+ }
61
+ ) {
62
+ return prisma.$transaction(async (tx) => {
63
+ const existing = await tx.academicYear.findFirst({ where: { id, schoolId } });
64
+ if (!existing) return null;
65
+ if (data.isCurrent) {
66
+ await tx.academicYear.updateMany({
67
+ where: { schoolId, isCurrent: true, NOT: { id } },
68
+ data: { isCurrent: false },
69
+ });
70
+ }
71
+ return tx.academicYear.update({
72
+ where: { id },
73
+ data,
74
+ include: {
75
+ terms: { orderBy: [{ isCurrent: "desc" }, { startDate: "asc" }] },
76
+ },
77
+ });
78
+ });
79
+ }
80
+
81
+ export function deleteAcademicYear(id: string, schoolId: string) {
82
+ return prisma.academicYear.deleteMany({ where: { id, schoolId } });
83
+ }
84
+
85
+ export function createAcademicTerm(data: {
86
+ academicYearId: string;
87
+ name: string;
88
+ startDate: Date;
89
+ endDate: Date;
90
+ isCurrent?: boolean;
91
+ }) {
92
+ return prisma.$transaction(async (tx) => {
93
+ if (data.isCurrent) {
94
+ await tx.academicTerm.updateMany({
95
+ where: { academicYearId: data.academicYearId, isCurrent: true },
96
+ data: { isCurrent: false },
97
+ });
98
+ }
99
+ return tx.academicTerm.create({ data: { ...data, isCurrent: data.isCurrent ?? false } });
100
+ });
101
+ }
102
+
103
+ export function updateAcademicTerm(
104
+ id: string,
105
+ academicYearId: string,
106
+ data: {
107
+ name?: string;
108
+ startDate?: Date;
109
+ endDate?: Date;
110
+ isCurrent?: boolean;
111
+ }
112
+ ) {
113
+ return prisma.$transaction(async (tx) => {
114
+ const existing = await tx.academicTerm.findFirst({
115
+ where: { id, academicYearId },
116
+ });
117
+ if (!existing) return null;
118
+ if (data.isCurrent) {
119
+ await tx.academicTerm.updateMany({
120
+ where: { academicYearId, isCurrent: true, NOT: { id } },
121
+ data: { isCurrent: false },
122
+ });
123
+ }
124
+ return tx.academicTerm.update({ where: { id }, data });
125
+ });
126
+ }
127
+
128
+ export function deleteAcademicTerm(id: string, academicYearId: string) {
129
+ return prisma.academicTerm.deleteMany({ where: { id, academicYearId } });
130
+ }
131
+
132
+ export function getCurrentAcademicYear(schoolId: string) {
133
+ return prisma.academicYear.findFirst({
134
+ where: { schoolId, isCurrent: true },
135
+ include: {
136
+ terms: { orderBy: [{ isCurrent: "desc" }, { startDate: "asc" }] },
137
+ },
138
+ });
139
+ }