@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,199 @@
1
+ /**
2
+ * Fee invoice / receipt PDF generation.
3
+ *
4
+ * Stamps content onto the school's uploaded letterhead when present:
5
+ * - PDF letterhead → reuse first page as background
6
+ * - Image letterhead (png/jpeg) → draw full-page background then text
7
+ * - No letterhead → plain A4 page with school name header
8
+ */
9
+ import {
10
+ PDFDocument,
11
+ StandardFonts,
12
+ rgb,
13
+ degrees,
14
+ } from "pdf-lib";
15
+ import fontkit from "@pdf-lib/fontkit";
16
+ import { readFileSync } from "fs";
17
+ import { join } from "path";
18
+ import { bucket } from "./googleCloudStorage.js";
19
+ import { logger } from "../utils/logger.js";
20
+ import { formatNpr, monthLabel } from "./feePdfFormat.js";
21
+
22
+ export { formatNpr, monthLabel };
23
+
24
+ export type FeePdfLine = { label: string; value: string };
25
+
26
+ export interface FeePdfDocumentInput {
27
+ schoolName: string;
28
+ title: string;
29
+ subtitle?: string;
30
+ lines: FeePdfLine[];
31
+ footerNote?: string;
32
+ letterhead?: {
33
+ path: string;
34
+ type: string;
35
+ } | null;
36
+ }
37
+
38
+ async function loadUnicodeFonts(pdfDoc: PDFDocument) {
39
+ pdfDoc.registerFontkit(fontkit);
40
+ const fontDir = join(process.cwd(), "src", "lib");
41
+ try {
42
+ const regular = readFileSync(join(fontDir, "NotoSans-Regular.ttf"));
43
+ const bold = readFileSync(join(fontDir, "NotoSans-Bold.ttf"));
44
+ return {
45
+ regular: await pdfDoc.embedFont(regular),
46
+ bold: await pdfDoc.embedFont(bold),
47
+ };
48
+ } catch (err) {
49
+ logger.warn(`feePdf: falling back to Helvetica fonts: ${err}`);
50
+ return {
51
+ regular: await pdfDoc.embedFont(StandardFonts.Helvetica),
52
+ bold: await pdfDoc.embedFont(StandardFonts.HelveticaBold),
53
+ };
54
+ }
55
+ }
56
+
57
+ async function loadLetterheadBytes(path: string): Promise<Uint8Array | null> {
58
+ try {
59
+ const [bytes] = await bucket.file(path).download();
60
+ return bytes;
61
+ } catch (err) {
62
+ logger.warn(`feePdf: failed to download letterhead ${path}: ${err}`);
63
+ return null;
64
+ }
65
+ }
66
+
67
+ async function preparePage(
68
+ pdfDoc: PDFDocument,
69
+ letterhead?: FeePdfDocumentInput["letterhead"]
70
+ ) {
71
+ if (letterhead?.path) {
72
+ const bytes = await loadLetterheadBytes(letterhead.path);
73
+ if (bytes) {
74
+ const type = (letterhead.type || "").toLowerCase();
75
+ if (type.includes("pdf") || letterhead.path.toLowerCase().endsWith(".pdf")) {
76
+ const src = await PDFDocument.load(bytes);
77
+ const [page] = await pdfDoc.copyPages(src, [0]);
78
+ pdfDoc.addPage(page);
79
+ return pdfDoc.getPage(0);
80
+ }
81
+ if (
82
+ type.includes("png") ||
83
+ type.includes("jpeg") ||
84
+ type.includes("jpg")
85
+ ) {
86
+ const page = pdfDoc.addPage();
87
+ const { width, height } = page.getSize();
88
+ try {
89
+ const image = type.includes("png")
90
+ ? await pdfDoc.embedPng(bytes)
91
+ : await pdfDoc.embedJpg(bytes);
92
+ page.drawImage(image, { x: 0, y: 0, width, height });
93
+ return page;
94
+ } catch (err) {
95
+ logger.warn(`feePdf: could not embed letterhead image: ${err}`);
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ return pdfDoc.addPage();
102
+ }
103
+
104
+ export async function buildFeePdf(
105
+ input: FeePdfDocumentInput
106
+ ): Promise<Uint8Array> {
107
+ const pdfDoc = await PDFDocument.create();
108
+ const fonts = await loadUnicodeFonts(pdfDoc);
109
+ const page = await preparePage(pdfDoc, input.letterhead);
110
+ const { width, height } = page.getSize();
111
+
112
+ const left = 56;
113
+ const right = width - 56;
114
+ let y = height - 120;
115
+
116
+ // When no letterhead, print school name as a simple header.
117
+ if (!input.letterhead?.path) {
118
+ page.drawText(input.schoolName, {
119
+ x: left,
120
+ y: height - 56,
121
+ size: 16,
122
+ font: fonts.bold,
123
+ color: rgb(0.1, 0.1, 0.1),
124
+ });
125
+ y = height - 96;
126
+ }
127
+
128
+ page.drawText(input.title, {
129
+ x: left,
130
+ y,
131
+ size: 18,
132
+ font: fonts.bold,
133
+ color: rgb(0.08, 0.08, 0.08),
134
+ });
135
+ y -= 22;
136
+
137
+ if (input.subtitle) {
138
+ page.drawText(input.subtitle, {
139
+ x: left,
140
+ y,
141
+ size: 11,
142
+ font: fonts.regular,
143
+ color: rgb(0.3, 0.3, 0.3),
144
+ });
145
+ y -= 28;
146
+ } else {
147
+ y -= 12;
148
+ }
149
+
150
+ page.drawLine({
151
+ start: { x: left, y },
152
+ end: { x: right, y },
153
+ thickness: 0.75,
154
+ color: rgb(0.75, 0.75, 0.75),
155
+ });
156
+ y -= 28;
157
+
158
+ for (const line of input.lines) {
159
+ page.drawText(line.label, {
160
+ x: left,
161
+ y,
162
+ size: 10,
163
+ font: fonts.regular,
164
+ color: rgb(0.35, 0.35, 0.35),
165
+ });
166
+ const valueWidth = fonts.bold.widthOfTextAtSize(line.value, 11);
167
+ page.drawText(line.value, {
168
+ x: Math.max(left + 160, right - valueWidth),
169
+ y,
170
+ size: 11,
171
+ font: fonts.bold,
172
+ color: rgb(0.1, 0.1, 0.1),
173
+ });
174
+ y -= 22;
175
+ if (y < 80) break;
176
+ }
177
+
178
+ if (input.footerNote) {
179
+ page.drawText(input.footerNote, {
180
+ x: left,
181
+ y: 48,
182
+ size: 9,
183
+ font: fonts.regular,
184
+ color: rgb(0.45, 0.45, 0.45),
185
+ });
186
+ }
187
+
188
+ // Light watermark so blank letterheads still look intentional.
189
+ page.drawText("Studious", {
190
+ x: width / 2 - 40,
191
+ y: 28,
192
+ size: 8,
193
+ font: fonts.regular,
194
+ color: rgb(0.7, 0.7, 0.7),
195
+ rotate: degrees(0),
196
+ });
197
+
198
+ return pdfDoc.save();
199
+ }
@@ -0,0 +1,14 @@
1
+ /** Pure formatting helpers for fee invoices / receipts (no I/O). */
2
+
3
+ export function formatNpr(amount: number) {
4
+ return `NPR ${amount.toLocaleString("en-NP")}`;
5
+ }
6
+
7
+ export function monthLabel(year: number, month: number) {
8
+ const d = new Date(Date.UTC(year, month - 1, 1));
9
+ return d.toLocaleString("en-GB", {
10
+ month: "long",
11
+ year: "numeric",
12
+ timeZone: "UTC",
13
+ });
14
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Student ID card PDF generation (front-side, CR80) via pdf-lib.
3
+ */
4
+ import fontkit from "@pdf-lib/fontkit";
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+ import {
8
+ PDFDocument,
9
+ PDFFont,
10
+ PDFImage,
11
+ PDFPage,
12
+ StandardFonts,
13
+ rgb,
14
+ type RGB,
15
+ } from "pdf-lib";
16
+ import sharp from "sharp";
17
+ import { logger } from "../utils/logger.js";
18
+
19
+ /** CR80 ID card size in PDF points (3.375" × 2.125"). */
20
+ export const ID_CARD_WIDTH = 243;
21
+ export const ID_CARD_HEIGHT = 153;
22
+
23
+ export const DEFAULT_BRAND_COLOR = "#1E3A5F";
24
+
25
+ export type IdCardStudent = {
26
+ displayName: string;
27
+ username: string;
28
+ /** Class / grade label, e.g. "Grade 5 · Section A". */
29
+ classLabel: string | null;
30
+ /** Hex accent from class/department branding when available. */
31
+ brandColor: string | null;
32
+ profilePictureUrl: string | null;
33
+ };
34
+
35
+ export type IdCardSchool = {
36
+ name: string;
37
+ logoPath: string | null;
38
+ };
39
+
40
+ function hexToRgb(cleaned: string): RGB {
41
+ const r = parseInt(cleaned.slice(0, 2), 16) / 255;
42
+ const g = parseInt(cleaned.slice(2, 4), 16) / 255;
43
+ const b = parseInt(cleaned.slice(4, 6), 16) / 255;
44
+ return rgb(r, g, b);
45
+ }
46
+
47
+ export function parseHexColor(hex: string | null | undefined): RGB {
48
+ const fallback = hexToRgb(DEFAULT_BRAND_COLOR.replace(/^#/, ""));
49
+ if (!hex) return fallback;
50
+ const cleaned = hex.trim().replace(/^#/, "");
51
+ if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return fallback;
52
+ return hexToRgb(cleaned);
53
+ }
54
+
55
+ function contrastTextColor(bg: RGB): RGB {
56
+ const luminance = 0.2126 * bg.red + 0.7152 * bg.green + 0.0722 * bg.blue;
57
+ return luminance > 0.55 ? rgb(0.1, 0.1, 0.1) : rgb(1, 1, 1);
58
+ }
59
+
60
+ async function loadFonts(pdfDoc: PDFDocument): Promise<{
61
+ regular: PDFFont;
62
+ bold: PDFFont;
63
+ }> {
64
+ pdfDoc.registerFontkit(fontkit);
65
+ const fontDir = join(process.cwd(), "src", "lib");
66
+ try {
67
+ const regularBytes = readFileSync(join(fontDir, "NotoSans-Regular.ttf"));
68
+ const boldBytes = readFileSync(join(fontDir, "NotoSans-Bold.ttf"));
69
+ return {
70
+ regular: await pdfDoc.embedFont(regularBytes),
71
+ bold: await pdfDoc.embedFont(boldBytes),
72
+ };
73
+ } catch (error) {
74
+ logger.warn(`idCardPdf: falling back to Helvetica fonts: ${error}`);
75
+ return {
76
+ regular: await pdfDoc.embedFont(StandardFonts.Helvetica),
77
+ bold: await pdfDoc.embedFont(StandardFonts.HelveticaBold),
78
+ };
79
+ }
80
+ }
81
+
82
+ async function fetchBytesFromUrl(url: string): Promise<Buffer | null> {
83
+ try {
84
+ const response = await fetch(url);
85
+ if (!response.ok) return null;
86
+ return Buffer.from(await response.arrayBuffer());
87
+ } catch (error) {
88
+ logger.warn(`idCardPdf: failed to fetch image URL: ${error}`);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function loadGcsBytes(path: string): Promise<Buffer | null> {
94
+ try {
95
+ // Dynamic import keeps unit tests free of GCS/env bootstrap.
96
+ const { bucket } = await import("./googleCloudStorage.js");
97
+ const [bytes] = await bucket.file(path).download();
98
+ return Buffer.from(bytes);
99
+ } catch (error) {
100
+ logger.warn(`idCardPdf: failed to download GCS object ${path}: ${error}`);
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Convert arbitrary image bytes (incl. SVG/WebP) to PNG for pdf-lib. */
106
+ export async function toPngBytes(input: Buffer): Promise<Uint8Array | null> {
107
+ try {
108
+ const png = await sharp(input).rotate().png().toBuffer();
109
+ return new Uint8Array(png);
110
+ } catch (error) {
111
+ logger.warn(`idCardPdf: sharp conversion failed: ${error}`);
112
+ return null;
113
+ }
114
+ }
115
+
116
+ async function embedImage(
117
+ pdfDoc: PDFDocument,
118
+ bytes: Buffer | null
119
+ ): Promise<PDFImage | null> {
120
+ if (!bytes || bytes.length === 0) return null;
121
+ const png = await toPngBytes(bytes);
122
+ if (!png) return null;
123
+ try {
124
+ return await pdfDoc.embedPng(png);
125
+ } catch (error) {
126
+ logger.warn(`idCardPdf: embedPng failed: ${error}`);
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function truncateToWidth(
132
+ text: string,
133
+ font: PDFFont,
134
+ size: number,
135
+ maxWidth: number
136
+ ): string {
137
+ if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
138
+ let truncated = text;
139
+ const ellipsis = "…";
140
+ while (
141
+ truncated.length > 1 &&
142
+ font.widthOfTextAtSize(truncated + ellipsis, size) > maxWidth
143
+ ) {
144
+ truncated = truncated.slice(0, -1);
145
+ }
146
+ return truncated + ellipsis;
147
+ }
148
+
149
+ function drawCard(
150
+ page: PDFPage,
151
+ fonts: { regular: PDFFont; bold: PDFFont },
152
+ school: IdCardSchool,
153
+ student: IdCardStudent,
154
+ logo: PDFImage | null,
155
+ photo: PDFImage | null
156
+ ) {
157
+ const brand = parseHexColor(student.brandColor ?? DEFAULT_BRAND_COLOR);
158
+ const onBrand = contrastTextColor(brand);
159
+ const muted = rgb(0.4, 0.45, 0.5);
160
+ const ink = rgb(0.12, 0.14, 0.18);
161
+ const border = rgb(0.85, 0.87, 0.9);
162
+
163
+ page.drawRectangle({
164
+ x: 0,
165
+ y: 0,
166
+ width: ID_CARD_WIDTH,
167
+ height: ID_CARD_HEIGHT,
168
+ color: rgb(1, 1, 1),
169
+ });
170
+
171
+ const headerH = 36;
172
+ page.drawRectangle({
173
+ x: 0,
174
+ y: ID_CARD_HEIGHT - headerH,
175
+ width: ID_CARD_WIDTH,
176
+ height: headerH,
177
+ color: brand,
178
+ });
179
+
180
+ page.drawRectangle({
181
+ x: 0,
182
+ y: 0,
183
+ width: ID_CARD_WIDTH,
184
+ height: 6,
185
+ color: brand,
186
+ });
187
+
188
+ page.drawRectangle({
189
+ x: 0.5,
190
+ y: 0.5,
191
+ width: ID_CARD_WIDTH - 1,
192
+ height: ID_CARD_HEIGHT - 1,
193
+ borderColor: border,
194
+ borderWidth: 0.75,
195
+ });
196
+
197
+ const logoSize = 22;
198
+ let schoolNameX = 12;
199
+ if (logo) {
200
+ const scale = Math.min(logoSize / logo.width, logoSize / logo.height);
201
+ const w = logo.width * scale;
202
+ const h = logo.height * scale;
203
+ page.drawImage(logo, {
204
+ x: 10,
205
+ y: ID_CARD_HEIGHT - headerH + (headerH - h) / 2,
206
+ width: w,
207
+ height: h,
208
+ });
209
+ schoolNameX = 10 + w + 8;
210
+ }
211
+
212
+ const schoolName = truncateToWidth(
213
+ school.name,
214
+ fonts.bold,
215
+ 11,
216
+ ID_CARD_WIDTH - schoolNameX - 10
217
+ );
218
+ page.drawText(schoolName, {
219
+ x: schoolNameX,
220
+ y: ID_CARD_HEIGHT - headerH + 13,
221
+ size: 11,
222
+ font: fonts.bold,
223
+ color: onBrand,
224
+ });
225
+
226
+ const photoX = 12;
227
+ const photoY = 22;
228
+ const photoW = 64;
229
+ const photoH = 80;
230
+ page.drawRectangle({
231
+ x: photoX,
232
+ y: photoY,
233
+ width: photoW,
234
+ height: photoH,
235
+ color: rgb(0.94, 0.95, 0.97),
236
+ borderColor: border,
237
+ borderWidth: 0.5,
238
+ });
239
+ if (photo) {
240
+ const scale = Math.min(photoW / photo.width, photoH / photo.height);
241
+ const w = photo.width * scale;
242
+ const h = photo.height * scale;
243
+ page.drawImage(photo, {
244
+ x: photoX + (photoW - w) / 2,
245
+ y: photoY + (photoH - h) / 2,
246
+ width: w,
247
+ height: h,
248
+ });
249
+ } else {
250
+ const initials = student.displayName
251
+ .split(/\s+/)
252
+ .filter(Boolean)
253
+ .slice(0, 2)
254
+ .map((p) => p[0]?.toUpperCase() ?? "")
255
+ .join("");
256
+ const initialsSize = 18;
257
+ const iw = fonts.bold.widthOfTextAtSize(initials || "?", initialsSize);
258
+ page.drawText(initials || "?", {
259
+ x: photoX + (photoW - iw) / 2,
260
+ y: photoY + photoH / 2 - initialsSize / 3,
261
+ size: initialsSize,
262
+ font: fonts.bold,
263
+ color: muted,
264
+ });
265
+ }
266
+
267
+ const textX = photoX + photoW + 12;
268
+ const maxTextW = ID_CARD_WIDTH - textX - 10;
269
+ let y = ID_CARD_HEIGHT - headerH - 22;
270
+
271
+ const drawField = (label: string, value: string, valueSize = 10) => {
272
+ page.drawText(label, {
273
+ x: textX,
274
+ y,
275
+ size: 7,
276
+ font: fonts.regular,
277
+ color: muted,
278
+ });
279
+ y -= 12;
280
+ const line = truncateToWidth(value, fonts.bold, valueSize, maxTextW);
281
+ page.drawText(line, {
282
+ x: textX,
283
+ y,
284
+ size: valueSize,
285
+ font: fonts.bold,
286
+ color: ink,
287
+ });
288
+ y -= 16;
289
+ };
290
+
291
+ drawField("Name", student.displayName || student.username, 11);
292
+ drawField("Student ID", student.username, 10);
293
+ drawField("Class / Grade", student.classLabel || "—", 9);
294
+
295
+ page.drawText("STUDENT", {
296
+ x: textX,
297
+ y: 14,
298
+ size: 7,
299
+ font: fonts.bold,
300
+ color: brand,
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Build a multi-page PDF: one CR80 front-side ID card per student.
306
+ */
307
+ export async function buildIdCardsPdf(
308
+ school: IdCardSchool,
309
+ students: IdCardStudent[]
310
+ ): Promise<Uint8Array> {
311
+ if (students.length === 0) {
312
+ throw new Error("No students provided for ID card generation");
313
+ }
314
+
315
+ const pdfDoc = await PDFDocument.create();
316
+ const fonts = await loadFonts(pdfDoc);
317
+
318
+ let logoBytes: Buffer | null = null;
319
+ if (school.logoPath) {
320
+ logoBytes = await loadGcsBytes(school.logoPath);
321
+ }
322
+ const logo = await embedImage(pdfDoc, logoBytes);
323
+
324
+ for (const student of students) {
325
+ let photoBytes: Buffer | null = null;
326
+ if (student.profilePictureUrl) {
327
+ photoBytes = await fetchBytesFromUrl(student.profilePictureUrl);
328
+ }
329
+ const photo = await embedImage(pdfDoc, photoBytes);
330
+
331
+ const page = pdfDoc.addPage([ID_CARD_WIDTH, ID_CARD_HEIGHT]);
332
+ drawCard(page, fonts, school, student, logo, photo);
333
+ }
334
+
335
+ return pdfDoc.save();
336
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Reusable PDF letterhead stamp helper (pdf-lib).
3
+ *
4
+ * Callers (transcripts, fee receipts, ID cards) load school letterhead bytes
5
+ * and pass them here to draw a header image on the first page.
6
+ */
7
+
8
+ import { PDFDocument, PDFImage, PDFPage } from "pdf-lib";
9
+
10
+ export interface StampLetterheadOptions {
11
+ /** Full page width (points). Defaults to A4 width. */
12
+ pageWidth?: number;
13
+ /** Top margin inset. Defaults to 24. */
14
+ topMargin?: number;
15
+ /** Max letterhead height in points. Defaults to 72. */
16
+ maxHeight?: number;
17
+ /** Horizontal inset from page edges. Defaults to 48. */
18
+ sideMargin?: number;
19
+ }
20
+
21
+ function looksLikePng(bytes: Uint8Array): boolean {
22
+ return (
23
+ bytes.length >= 8 &&
24
+ bytes[0] === 0x89 &&
25
+ bytes[1] === 0x50 &&
26
+ bytes[2] === 0x4e &&
27
+ bytes[3] === 0x47
28
+ );
29
+ }
30
+
31
+ async function embedImage(
32
+ pdfDoc: PDFDocument,
33
+ bytes: Uint8Array,
34
+ contentType?: string | null
35
+ ): Promise<PDFImage | null> {
36
+ if (!bytes.length) return null;
37
+ const type = (contentType ?? "").toLowerCase();
38
+ try {
39
+ if (type.includes("png") || (!type && looksLikePng(bytes))) {
40
+ return await pdfDoc.embedPng(bytes);
41
+ }
42
+ return await pdfDoc.embedJpg(bytes);
43
+ } catch {
44
+ try {
45
+ return await pdfDoc.embedPng(bytes);
46
+ } catch {
47
+ try {
48
+ return await pdfDoc.embedJpg(bytes);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Draw letterhead on `page` and return the Y coordinate below the image
58
+ * (content should start at or below this value). Returns the original
59
+ * `contentTopY` when letterhead cannot be embedded.
60
+ */
61
+ export async function stampLetterheadOnPage(input: {
62
+ pdfDoc: PDFDocument;
63
+ page: PDFPage;
64
+ letterheadBytes?: Uint8Array | null;
65
+ letterheadContentType?: string | null;
66
+ /** Current top Y for content (page height - top margin). */
67
+ contentTopY: number;
68
+ options?: StampLetterheadOptions;
69
+ }): Promise<number> {
70
+ const {
71
+ pdfDoc,
72
+ page,
73
+ letterheadBytes,
74
+ letterheadContentType,
75
+ contentTopY,
76
+ options,
77
+ } = input;
78
+ if (!letterheadBytes?.length) return contentTopY;
79
+
80
+ const pageWidth = options?.pageWidth ?? page.getWidth();
81
+ const sideMargin = options?.sideMargin ?? 48;
82
+ const maxHeight = options?.maxHeight ?? 72;
83
+
84
+ const image = await embedImage(pdfDoc, letterheadBytes, letterheadContentType);
85
+ if (!image) return contentTopY;
86
+
87
+ const maxWidth = pageWidth - sideMargin * 2;
88
+ const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1);
89
+ const drawWidth = image.width * scale;
90
+ const drawHeight = image.height * scale;
91
+ const x = (pageWidth - drawWidth) / 2;
92
+ const y = contentTopY - drawHeight;
93
+
94
+ page.drawImage(image, { x, y, width: drawWidth, height: drawHeight });
95
+
96
+ // Leave a small gap under the letterhead.
97
+ return y - 16;
98
+ }
99
+
100
+ /**
101
+ * Convenience: load an existing PDF, stamp letterhead on page 0, return bytes.
102
+ */
103
+ export async function stampLetterheadOnPdfBytes(input: {
104
+ pdfBytes: Uint8Array;
105
+ letterheadBytes?: Uint8Array | null;
106
+ letterheadContentType?: string | null;
107
+ options?: StampLetterheadOptions;
108
+ }): Promise<Uint8Array> {
109
+ const pdfDoc = await PDFDocument.load(input.pdfBytes);
110
+ const page = pdfDoc.getPages()[0];
111
+ if (!page) return input.pdfBytes;
112
+
113
+ const topMargin = input.options?.topMargin ?? 24;
114
+ await stampLetterheadOnPage({
115
+ pdfDoc,
116
+ page,
117
+ letterheadBytes: input.letterheadBytes,
118
+ letterheadContentType: input.letterheadContentType,
119
+ contentTopY: page.getHeight() - topMargin,
120
+ options: input.options,
121
+ });
122
+
123
+ return pdfDoc.save();
124
+ }