@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,77 @@
1
+ /**
2
+ * C7: Fees authorization stubs for ACCOUNTANT (+ ADMIN).
3
+ *
4
+ * A2 owns full fee models; this module only provides reusable gates that
5
+ * fee routers can call once they land.
6
+ */
7
+ import { SchoolMembershipRole } from "@prisma/client";
8
+ import { TRPCError } from "@trpc/server";
9
+ import { findActiveSchoolMembership } from "../models/school.js";
10
+
11
+ const FEE_WRITE_ROLES: SchoolMembershipRole[] = ["ADMIN", "ACCOUNTANT"];
12
+ const FEE_READ_ROLES: SchoolMembershipRole[] = [
13
+ "ADMIN",
14
+ "ACCOUNTANT",
15
+ "TEACHER",
16
+ "PARENT",
17
+ "STUDENT",
18
+ ];
19
+
20
+ export async function requireSchoolRoles(
21
+ schoolId: string,
22
+ userId: string,
23
+ allowed: SchoolMembershipRole[],
24
+ message = "You do not have permission for this action"
25
+ ) {
26
+ const membership = await findActiveSchoolMembership(schoolId, userId);
27
+ if (!membership || !allowed.includes(membership.role)) {
28
+ throw new TRPCError({ code: "FORBIDDEN", message });
29
+ }
30
+ return membership;
31
+ }
32
+
33
+ /** Admin or accountant may create/update fee structures and record payments. */
34
+ export async function requireFeeWriteAccess(schoolId: string, userId: string) {
35
+ return requireSchoolRoles(
36
+ schoolId,
37
+ userId,
38
+ FEE_WRITE_ROLES,
39
+ "Only school admins or accountants can manage fees"
40
+ );
41
+ }
42
+
43
+ /** Broad read access stub — parents/students should be scoped to own records by A2. */
44
+ export async function requireFeeReadAccess(schoolId: string, userId: string) {
45
+ return requireSchoolRoles(
46
+ schoolId,
47
+ userId,
48
+ FEE_READ_ROLES,
49
+ "You are not a member of this school"
50
+ );
51
+ }
52
+
53
+ export function canManageFees(role: SchoolMembershipRole) {
54
+ return FEE_WRITE_ROLES.includes(role);
55
+ }
56
+
57
+ /**
58
+ * Placeholder procedure surface until A2 fee models exist.
59
+ * Returns capability flags so the client can show/hide fee UI.
60
+ */
61
+ export async function getFeeCapabilities(userId: string, schoolId: string) {
62
+ const membership = await findActiveSchoolMembership(schoolId, userId);
63
+ if (!membership) {
64
+ throw new TRPCError({
65
+ code: "FORBIDDEN",
66
+ message: "You are not a member of this school",
67
+ });
68
+ }
69
+ return {
70
+ role: membership.role,
71
+ canManageFees: canManageFees(membership.role),
72
+ canViewFees: FEE_READ_ROLES.includes(membership.role),
73
+ feesImplemented: false as const,
74
+ message:
75
+ "Fee models are not implemented on this branch yet (owned by A2). Authz stubs are ready.",
76
+ };
77
+ }
@@ -325,6 +325,76 @@ export async function getComputedGradebook(classId: string, userId: string) {
325
325
  };
326
326
  }
327
327
 
328
+ /**
329
+ * Compute one student's final grade in a class without teacher-auth filtering.
330
+ * Used by school-admin transcripts (caller must enforce school admin separately).
331
+ */
332
+ export async function computeStudentGradeInClass(
333
+ classId: string,
334
+ studentId: string
335
+ ): Promise<{
336
+ result: FinalGradeResult;
337
+ letterGrade: string | null;
338
+ gradingBoundary: { id: string; name: string | null; structured: string } | null;
339
+ studentName: string | null;
340
+ } | null> {
341
+ const [classData, categories, columns] = await Promise.all([
342
+ findClassForGradebook(classId),
343
+ findGradeCategoriesByClassId(classId),
344
+ findCustomGradeColumnsByClassId(classId),
345
+ ]);
346
+ if (!classData) return null;
347
+
348
+ const student = classData.students.find((s) => s.id === studentId);
349
+ if (!student) return null;
350
+
351
+ const policies = {
352
+ treatMissingAsZero: classData.treatMissingAsZero,
353
+ roundingMode: classData.gradeRoundingMode,
354
+ };
355
+ const categoryInputs: GradeCategoryInput[] = categories.map((category) => ({
356
+ id: category.id,
357
+ name: category.name,
358
+ weight: category.weight,
359
+ dropLowest: category.dropLowest,
360
+ }));
361
+ const boundary = classData.gradingBoundaries[0] ?? null;
362
+
363
+ const scores: ScoreInput[] = [];
364
+ for (const assignment of classData.assignments) {
365
+ const submission = assignment.submissions.find(
366
+ (item) => item.studentId === studentId
367
+ );
368
+ scores.push({
369
+ categoryId: assignment.gradeCategoryId,
370
+ pointsEarned: submission?.gradeReceived ?? null,
371
+ maxPoints: assignment.maxGrade ?? 0,
372
+ weight: assignment.weight,
373
+ });
374
+ }
375
+ for (const column of columns) {
376
+ const entry = column.entries.find((item) => item.studentId === studentId);
377
+ scores.push({
378
+ categoryId: column.gradeCategoryId,
379
+ pointsEarned: entry?.points ?? null,
380
+ maxPoints: column.maxPoints,
381
+ });
382
+ }
383
+
384
+ const result = computeFinalGrade(policies, categoryInputs, scores);
385
+ const letterGrade =
386
+ boundary && result.percentage !== null
387
+ ? resolveLetterGrade(boundary.structured, result.percentage)
388
+ : null;
389
+
390
+ return {
391
+ result,
392
+ letterGrade,
393
+ gradingBoundary: boundary,
394
+ studentName: student.profile?.displayName || student.username,
395
+ };
396
+ }
397
+
328
398
  // ===== CSV export =====
329
399
 
330
400
  function escapeCsvCell(value: string): string {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * School admin ID card generation — bulk PDF for selected student members.
3
+ */
4
+ import { TRPCError } from "@trpc/server";
5
+ import { prisma } from "../lib/prisma.js";
6
+ import {
7
+ buildIdCardsPdf,
8
+ DEFAULT_BRAND_COLOR,
9
+ type IdCardStudent,
10
+ } from "../lib/idCardPdf.js";
11
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
12
+
13
+ const MAX_STUDENTS = 100;
14
+
15
+ async function requireSchoolAdmin(schoolId: string, userId: string) {
16
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
17
+ if (!membership) {
18
+ throw new TRPCError({
19
+ code: "FORBIDDEN",
20
+ message: "Only school admins can generate ID cards",
21
+ });
22
+ }
23
+ return membership;
24
+ }
25
+
26
+ function formatClassLabel(cls: {
27
+ name: string;
28
+ section: string;
29
+ subject: string;
30
+ }): string {
31
+ const parts = [cls.name, cls.section].filter(
32
+ (p) => p && p.trim().length > 0
33
+ );
34
+ if (parts.length === 0) return cls.subject || "—";
35
+ return parts.join(" · ");
36
+ }
37
+
38
+ export async function generateStudentIdCards(
39
+ userId: string,
40
+ input: { schoolId: string; studentUserIds: string[] }
41
+ ) {
42
+ await requireSchoolAdmin(input.schoolId, userId);
43
+
44
+ const uniqueIds = [...new Set(input.studentUserIds)].slice(0, MAX_STUDENTS);
45
+ if (uniqueIds.length === 0) {
46
+ throw new TRPCError({
47
+ code: "BAD_REQUEST",
48
+ message: "Select at least one student",
49
+ });
50
+ }
51
+
52
+ const school = await prisma.school.findUnique({
53
+ where: { id: input.schoolId },
54
+ select: {
55
+ id: true,
56
+ name: true,
57
+ logo: { select: { path: true } },
58
+ },
59
+ });
60
+ if (!school) {
61
+ throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
62
+ }
63
+
64
+ const memberships = await prisma.schoolMembership.findMany({
65
+ where: {
66
+ schoolId: input.schoolId,
67
+ userId: { in: uniqueIds },
68
+ role: "STUDENT",
69
+ status: "ACTIVE",
70
+ },
71
+ select: {
72
+ user: {
73
+ select: {
74
+ id: true,
75
+ username: true,
76
+ profile: {
77
+ select: {
78
+ displayName: true,
79
+ profilePicture: true,
80
+ },
81
+ },
82
+ studentIn: {
83
+ where: { schoolId: input.schoolId },
84
+ select: {
85
+ name: true,
86
+ section: true,
87
+ subject: true,
88
+ color: true,
89
+ department: { select: { color: true } },
90
+ },
91
+ orderBy: { name: "asc" },
92
+ take: 1,
93
+ },
94
+ },
95
+ },
96
+ },
97
+ });
98
+
99
+ if (memberships.length === 0) {
100
+ throw new TRPCError({
101
+ code: "NOT_FOUND",
102
+ message: "No active student members found for the given IDs",
103
+ });
104
+ }
105
+
106
+ const byId = new Map(memberships.map((m) => [m.user.id, m.user]));
107
+ const students: IdCardStudent[] = [];
108
+ for (const id of uniqueIds) {
109
+ const user = byId.get(id);
110
+ if (!user) continue;
111
+ const primaryClass = user.studentIn[0] ?? null;
112
+ const brandColor =
113
+ primaryClass?.color ??
114
+ primaryClass?.department?.color ??
115
+ DEFAULT_BRAND_COLOR;
116
+
117
+ students.push({
118
+ displayName: user.profile?.displayName?.trim() || user.username,
119
+ username: user.username,
120
+ classLabel: primaryClass ? formatClassLabel(primaryClass) : null,
121
+ brandColor,
122
+ profilePictureUrl: user.profile?.profilePicture ?? null,
123
+ });
124
+ }
125
+
126
+ const pdfBytes = await buildIdCardsPdf(
127
+ {
128
+ name: school.name,
129
+ logoPath: school.logo?.path ?? null,
130
+ },
131
+ students
132
+ );
133
+
134
+ const dateStamp = new Date().toISOString().slice(0, 10);
135
+ const fileName =
136
+ students.length === 1
137
+ ? `id-card-${students[0].username}-${dateStamp}.pdf`
138
+ : `id-cards-${school.name.replace(/[^\w\-]+/g, "_").slice(0, 40)}-${dateStamp}.pdf`;
139
+
140
+ return {
141
+ pdfBase64: Buffer.from(pdfBytes).toString("base64"),
142
+ fileName,
143
+ mimeType: "application/pdf" as const,
144
+ studentCount: students.length,
145
+ skippedCount: uniqueIds.length - students.length,
146
+ };
147
+ }