@studious-lms/server 1.23.0 → 1.25.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 +547 -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 +180 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +116 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +308 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +93 -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 +12024 -7778
  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 +61 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +170 -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 +110 -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 +242 -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 +327 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +389 -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 +34 -2
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +27 -4
  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 +330 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +46 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +49 -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 +631 -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 +204 -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 +131 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +314 -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 +98 -54
  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 +249 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +60 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +231 -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 +402 -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 +702 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +161 -0
  257. package/src/models/moduleItem.ts +125 -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 +91 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +149 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +327 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +552 -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 +26 -2
  278. package/src/services/fees.ts +447 -0
  279. package/src/services/feesAuthz.ts +73 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +790 -0
  283. package/src/services/moduleItem.ts +298 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +367 -0
  286. package/src/services/school.ts +100 -54
  287. package/src/services/studentImport.ts +285 -0
  288. package/src/services/transcript.ts +301 -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
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Attendance service – get and update class attendance records.
3
- * Auto-creates attendance records for events that don't have them yet.
2
+ * Attendance service – class attendance + school-admin excusals / stats.
3
+ * Dual-writes legacy present/late/absent lists and per-student AttendanceRecord.
4
+ * Active StudentExcusal rows force EXCUSED (locked for teachers).
4
5
  */
5
6
  import { TRPCError } from "@trpc/server";
6
7
  import {
@@ -13,9 +14,52 @@ import {
13
14
  createAttendance,
14
15
  createAttendanceForEvent,
15
16
  updateAttendance,
17
+ findActiveExcusalsForStudents,
18
+ listExcusalsForSchool,
19
+ findExcusalById,
20
+ createStudentExcusal,
21
+ updateStudentExcusal,
22
+ revokeStudentExcusal,
23
+ applyExcusalToAttendance,
24
+ clearExcusalLocks,
25
+ listSchoolStudentMemberships,
26
+ listAttendanceRecordsForSchoolStudents,
27
+ listTodaySchoolAttendanceSessions,
28
+ toDateOnly,
29
+ startOfUtcDay,
30
+ endOfUtcDay,
16
31
  } from "../models/attendance.js";
32
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
17
33
  import { sendToMultiple } from "./notification.js";
18
34
  import { logger } from "../utils/logger.js";
35
+ import {
36
+ buildExcusalLocks,
37
+ mergeAttendanceUpdateWithLocks,
38
+ type StudentRef,
39
+ } from "./attendanceMerge.js";
40
+ import {
41
+ computeAttendancePercentage,
42
+ emptyAttendanceCounts,
43
+ tallyAttendanceStatuses,
44
+ } from "./attendanceStats.js";
45
+
46
+ async function requireSchoolAdmin(schoolId: string, userId: string) {
47
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
48
+ if (!membership) {
49
+ throw new TRPCError({
50
+ code: "FORBIDDEN",
51
+ message: "Only school admins can perform this action",
52
+ });
53
+ }
54
+ return membership;
55
+ }
56
+
57
+ function sessionDate(attendance: {
58
+ date: Date;
59
+ event?: { startTime: Date } | null;
60
+ }): Date {
61
+ return attendance.event?.startTime ?? attendance.date;
62
+ }
19
63
 
20
64
  /** Get attendance for a class (optionally filtered by event). Creates missing records. */
21
65
  export async function getAttendance(classId: string, eventId?: string) {
@@ -33,26 +77,127 @@ export async function getAttendance(classId: string, eventId?: string) {
33
77
  for (const event of events) {
34
78
  const existing = await findAttendanceByEventId(event.id);
35
79
  if (!existing) {
80
+ const locks =
81
+ classData.schoolId && studentIds.length > 0
82
+ ? buildExcusalLocks(
83
+ await findActiveExcusalsForStudents({
84
+ schoolId: classData.schoolId,
85
+ studentIds,
86
+ date: event.startTime,
87
+ }),
88
+ studentIds
89
+ )
90
+ : [];
91
+
92
+ const merged = mergeAttendanceUpdateWithLocks({
93
+ present: classData.students.map((s) => ({
94
+ id: s.id,
95
+ username: s.username,
96
+ })),
97
+ late: [],
98
+ absent: [],
99
+ locks,
100
+ });
101
+
36
102
  await createAttendanceForEvent({
37
103
  classId,
38
104
  eventId: event.id,
39
- presentIds: studentIds,
105
+ date: event.startTime,
106
+ presentIds: merged.presentIds,
107
+ lateIds: merged.lateIds,
108
+ absentIds: merged.absentIds,
109
+ excusedIds: merged.excusedIds,
110
+ records: merged.records,
111
+ });
112
+ } else if (classData.schoolId && studentIds.length > 0) {
113
+ // Ensure active excusals are reflected on existing sessions.
114
+ const locks = buildExcusalLocks(
115
+ await findActiveExcusalsForStudents({
116
+ schoolId: classData.schoolId,
117
+ studentIds,
118
+ date: event.startTime,
119
+ }),
120
+ studentIds
121
+ );
122
+ const needsLock = locks.filter((lock) => {
123
+ const current = existing.records.find((r) => r.studentId === lock.studentId);
124
+ return !(
125
+ current?.status === "EXCUSED" &&
126
+ current.excusedByExcusalId === lock.excusalId
127
+ );
40
128
  });
129
+ const byExcusal = new Map<string, string[]>();
130
+ for (const lock of needsLock) {
131
+ const list = byExcusal.get(lock.excusalId) ?? [];
132
+ list.push(lock.studentId);
133
+ byExcusal.set(lock.excusalId, list);
134
+ }
135
+ for (const [excusalId, lockedStudentIds] of byExcusal) {
136
+ await applyExcusalToAttendance({
137
+ schoolId: classData.schoolId,
138
+ excusalId,
139
+ studentIds: lockedStudentIds,
140
+ startDate: event.startTime,
141
+ endDate: event.startTime,
142
+ });
143
+ }
41
144
  }
42
145
  }
43
146
 
44
- return findManyAttendance(classId, eventId);
147
+ const sessions = await findManyAttendance(classId, eventId);
148
+ return sessions.map(shapeAttendanceSession);
149
+ }
150
+
151
+ /** Additive shape: keep present/late/absent; expose excused + lock metadata for UI. */
152
+ function shapeAttendanceSession<
153
+ T extends {
154
+ records: Array<{
155
+ id: string;
156
+ studentId: string;
157
+ status: string;
158
+ note: string | null;
159
+ excusedByExcusalId: string | null;
160
+ updatedById: string | null;
161
+ updatedAt: Date;
162
+ student: { id: string; username: string };
163
+ excusedByExcusal: {
164
+ id: string;
165
+ reason: string;
166
+ note: string | null;
167
+ } | null;
168
+ }>;
169
+ present: unknown[];
170
+ late: unknown[];
171
+ absent: unknown[];
172
+ },
173
+ >(session: T) {
174
+ const records = session.records.map((r) => ({
175
+ ...r,
176
+ lockedFromExcusal: Boolean(r.excusedByExcusalId),
177
+ reason: r.excusedByExcusal?.reason ?? null,
178
+ excusalNote: r.excusedByExcusal?.note ?? null,
179
+ }));
180
+ const excused = records
181
+ .filter((r) => r.status === "EXCUSED")
182
+ .map((r) => r.student);
183
+
184
+ return {
185
+ ...session,
186
+ excused,
187
+ records,
188
+ };
45
189
  }
46
190
 
47
- /** Update attendance record (present/late/absent). Teacher-only. */
191
+ /** Update attendance record (present/late/absent/excused). Teacher-only. */
48
192
  export async function updateAttendanceRecord(
49
193
  userId: string,
50
194
  classId: string,
51
195
  eventId: string | null | undefined,
52
196
  attendance: {
53
- present: { id: string; username: string }[];
54
- late: { id: string; username: string }[];
55
- absent: { id: string; username: string }[];
197
+ present: StudentRef[];
198
+ late: StudentRef[];
199
+ absent: StudentRef[];
200
+ excused?: StudentRef[];
56
201
  }
57
202
  ) {
58
203
  const classData = await findClassWithTeacher(classId, userId);
@@ -64,55 +209,433 @@ export async function updateAttendanceRecord(
64
209
  }
65
210
 
66
211
  const existing = await findAttendanceByClassAndEvent(classId, eventId ?? null);
212
+ const dateForExcusal = existing
213
+ ? sessionDate(existing)
214
+ : new Date();
215
+
216
+ const classStudentIds = classData.students.map((s) => s.id);
217
+ const locks =
218
+ classData.schoolId && classStudentIds.length > 0
219
+ ? buildExcusalLocks(
220
+ await findActiveExcusalsForStudents({
221
+ schoolId: classData.schoolId,
222
+ studentIds: classStudentIds,
223
+ date: dateForExcusal,
224
+ }),
225
+ classStudentIds
226
+ )
227
+ : [];
228
+
229
+ const existingNotes = new Map(
230
+ (existing?.records ?? []).map((r) => [r.studentId, r.note])
231
+ );
232
+
233
+ const merged = mergeAttendanceUpdateWithLocks({
234
+ present: attendance.present,
235
+ late: attendance.late,
236
+ absent: attendance.absent,
237
+ excused: attendance.excused,
238
+ locks,
239
+ updatedById: userId,
240
+ existingNotesByStudentId: existingNotes,
241
+ });
67
242
 
68
- const presentIds = attendance.present.map((s) => s.id);
69
- const lateIds = attendance.late.map((s) => s.id);
70
- const absentIds = attendance.absent.map((s) => s.id);
71
243
  const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
72
- const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);
73
- const classStudentIds = new Set(classData.students.map((student) => student.id));
244
+ const previousAbsentIds = new Set(
245
+ existing?.absent.map((student) => student.id) ?? []
246
+ );
247
+ const classStudentIdSet = new Set(classStudentIds);
74
248
 
75
249
  if (!existing) {
76
250
  const created = await createAttendance({
77
251
  classId,
78
252
  eventId: eventId ?? null,
79
- presentIds,
80
- lateIds,
81
- absentIds,
253
+ presentIds: merged.presentIds,
254
+ lateIds: merged.lateIds,
255
+ absentIds: merged.absentIds,
256
+ excusedIds: merged.excusedIds,
257
+ records: merged.records,
258
+ updatedById: userId,
82
259
  });
83
260
  await notifyMarkedLateOrAbsent({
84
261
  classId,
85
262
  className: classData.name,
86
263
  eventId: eventId ?? null,
87
264
  eventName: created.event?.name ?? null,
88
- classStudentIds,
89
- lateIds,
90
- absentIds,
265
+ classStudentIds: classStudentIdSet,
266
+ lateIds: merged.lateIds,
267
+ absentIds: merged.absentIds,
91
268
  previousLateIds,
92
269
  previousAbsentIds,
93
270
  });
94
- return created;
271
+ return shapeAttendanceSession(created);
95
272
  }
96
273
 
97
274
  const updated = await updateAttendance(existing.id, {
98
- presentIds,
99
- lateIds,
100
- absentIds,
275
+ presentIds: merged.presentIds,
276
+ lateIds: merged.lateIds,
277
+ absentIds: merged.absentIds,
278
+ records: merged.records,
101
279
  });
102
280
  await notifyMarkedLateOrAbsent({
103
281
  classId,
104
282
  className: classData.name,
105
283
  eventId: eventId ?? null,
106
284
  eventName: existing.event?.name ?? null,
107
- classStudentIds,
108
- lateIds,
109
- absentIds,
285
+ classStudentIds: classStudentIdSet,
286
+ lateIds: merged.lateIds,
287
+ absentIds: merged.absentIds,
110
288
  previousLateIds,
111
289
  previousAbsentIds,
112
290
  });
291
+ return shapeAttendanceSession(updated);
292
+ }
293
+
294
+ /**
295
+ * School-admin: today's attendance rollup across classes.
296
+ * `percentage` is session-weighted (share of attended class-session records),
297
+ * not a share of `totalStudents`; a student in six classes contributes six records.
298
+ */
299
+ export async function listTodayAttendanceSummary(
300
+ userId: string,
301
+ schoolId: string
302
+ ) {
303
+ await requireSchoolAdmin(schoolId, userId);
304
+
305
+ const today = new Date();
306
+ const sessions = await listTodaySchoolAttendanceSessions(schoolId, today);
307
+ const memberships = await listSchoolStudentMemberships(schoolId);
308
+ const totalStudents = memberships.length;
309
+
310
+ const counts = emptyAttendanceCounts();
311
+ const rows: Array<{
312
+ attendanceId: string;
313
+ classId: string;
314
+ className: string;
315
+ section: string;
316
+ subject: string;
317
+ eventId: string | null;
318
+ eventName: string | null;
319
+ date: Date;
320
+ present: number;
321
+ late: number;
322
+ absent: number;
323
+ excused: number;
324
+ unmarked: number;
325
+ classStudentCount: number;
326
+ }> = [];
327
+
328
+ for (const session of sessions) {
329
+ const tallied = tallyAttendanceStatuses(session.records.map((r) => r.status));
330
+ counts.present += tallied.present;
331
+ counts.late += tallied.late;
332
+ counts.absent += tallied.absent;
333
+ counts.excused += tallied.excused;
334
+ const classStudentCount = session.class._count.students;
335
+ const marked =
336
+ tallied.present + tallied.late + tallied.absent + tallied.excused;
337
+ rows.push({
338
+ attendanceId: session.id,
339
+ classId: session.classId,
340
+ className: session.class.name,
341
+ section: session.class.section,
342
+ subject: session.class.subject,
343
+ eventId: session.eventId,
344
+ eventName: session.event?.name ?? null,
345
+ date: session.event?.startTime ?? session.date,
346
+ present: tallied.present,
347
+ late: tallied.late,
348
+ absent: tallied.absent,
349
+ excused: tallied.excused,
350
+ unmarked: Math.max(0, classStudentCount - marked),
351
+ classStudentCount,
352
+ });
353
+ }
354
+
355
+ return {
356
+ date: toDateOnly(today),
357
+ totalStudents,
358
+ totals: counts,
359
+ percentage: computeAttendancePercentage(counts),
360
+ sessions: rows,
361
+ };
362
+ }
363
+
364
+ /** School-admin: per-student attendance counts + % (excused excluded from %). */
365
+ export async function listStudentAttendanceStats(
366
+ userId: string,
367
+ input: {
368
+ schoolId: string;
369
+ from?: Date;
370
+ to?: Date;
371
+ studentId?: string;
372
+ }
373
+ ) {
374
+ await requireSchoolAdmin(input.schoolId, userId);
375
+
376
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
377
+ const filtered = input.studentId
378
+ ? memberships.filter((m) => m.userId === input.studentId)
379
+ : memberships;
380
+ const studentIds = filtered.map((m) => m.userId);
381
+
382
+ const records = await listAttendanceRecordsForSchoolStudents({
383
+ schoolId: input.schoolId,
384
+ studentIds,
385
+ from: input.from,
386
+ to: input.to,
387
+ });
388
+
389
+ const byStudent = new Map<string, ReturnType<typeof emptyAttendanceCounts>>();
390
+ for (const id of studentIds) {
391
+ byStudent.set(id, emptyAttendanceCounts());
392
+ }
393
+ for (const record of records) {
394
+ const counts = byStudent.get(record.studentId);
395
+ if (!counts) continue;
396
+ switch (record.status) {
397
+ case "PRESENT":
398
+ counts.present += 1;
399
+ break;
400
+ case "LATE":
401
+ counts.late += 1;
402
+ break;
403
+ case "ABSENT":
404
+ counts.absent += 1;
405
+ break;
406
+ case "EXCUSED":
407
+ counts.excused += 1;
408
+ break;
409
+ default:
410
+ break;
411
+ }
412
+ }
413
+
414
+ return filtered.map((membership) => {
415
+ const counts = byStudent.get(membership.userId) ?? emptyAttendanceCounts();
416
+ return {
417
+ studentId: membership.userId,
418
+ username: membership.user.username,
419
+ displayName: membership.user.profile?.displayName ?? null,
420
+ profilePicture: membership.user.profile?.profilePicture ?? null,
421
+ profilePictureThumbnail:
422
+ membership.user.profile?.profilePictureThumbnail ?? null,
423
+ classes: membership.user.studentIn,
424
+ present: counts.present,
425
+ late: counts.late,
426
+ absent: counts.absent,
427
+ excused: counts.excused,
428
+ attendancePercentage: computeAttendancePercentage(counts),
429
+ };
430
+ });
431
+ }
432
+
433
+ export async function listExcusals(
434
+ userId: string,
435
+ input: {
436
+ schoolId: string;
437
+ includeRevoked?: boolean;
438
+ studentId?: string;
439
+ }
440
+ ) {
441
+ await requireSchoolAdmin(input.schoolId, userId);
442
+
443
+ const excusals = await listExcusalsForSchool(input);
444
+ const now = toDateOnly(new Date());
445
+
446
+ return excusals.map((excusal) => {
447
+ let status: "ACTIVE" | "UPCOMING" | "EXPIRED" | "REVOKED" = "ACTIVE";
448
+ if (excusal.revokedAt) {
449
+ status = "REVOKED";
450
+ } else if (toDateOnly(excusal.startDate) > now) {
451
+ status = "UPCOMING";
452
+ } else if (toDateOnly(excusal.endDate) < now) {
453
+ status = "EXPIRED";
454
+ }
455
+
456
+ return {
457
+ id: excusal.id,
458
+ schoolId: excusal.schoolId,
459
+ startDate: excusal.startDate,
460
+ endDate: excusal.endDate,
461
+ reason: excusal.reason,
462
+ note: excusal.note,
463
+ revokedAt: excusal.revokedAt,
464
+ createdAt: excusal.createdAt,
465
+ updatedAt: excusal.updatedAt,
466
+ status,
467
+ studentIds: excusal.students.map((s) => s.studentId),
468
+ students: excusal.students.map((s) => ({
469
+ id: s.student.id,
470
+ username: s.student.username,
471
+ displayName: s.student.profile?.displayName ?? null,
472
+ profilePicture: s.student.profile?.profilePicture ?? null,
473
+ profilePictureThumbnail:
474
+ s.student.profile?.profilePictureThumbnail ?? null,
475
+ })),
476
+ createdBy: {
477
+ id: excusal.createdBy.id,
478
+ username: excusal.createdBy.username,
479
+ displayName: excusal.createdBy.profile?.displayName ?? null,
480
+ },
481
+ revokedBy: excusal.revokedBy
482
+ ? {
483
+ id: excusal.revokedBy.id,
484
+ username: excusal.revokedBy.username,
485
+ displayName: excusal.revokedBy.profile?.displayName ?? null,
486
+ }
487
+ : null,
488
+ };
489
+ });
490
+ }
491
+
492
+ export async function createExcusal(
493
+ userId: string,
494
+ input: {
495
+ schoolId: string;
496
+ studentIds: string[];
497
+ startDate: Date;
498
+ endDate: Date;
499
+ reason: string;
500
+ note?: string | null;
501
+ }
502
+ ) {
503
+ await requireSchoolAdmin(input.schoolId, userId);
504
+
505
+ if (toDateOnly(input.endDate) < toDateOnly(input.startDate)) {
506
+ throw new TRPCError({
507
+ code: "BAD_REQUEST",
508
+ message: "endDate must be on or after startDate",
509
+ });
510
+ }
511
+
512
+ const uniqueStudentIds = [...new Set(input.studentIds)];
513
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
514
+ const schoolStudentIds = new Set(memberships.map((m) => m.userId));
515
+ const invalid = uniqueStudentIds.filter((id) => !schoolStudentIds.has(id));
516
+ if (invalid.length > 0) {
517
+ throw new TRPCError({
518
+ code: "BAD_REQUEST",
519
+ message: "One or more students are not active members of this school",
520
+ });
521
+ }
522
+
523
+ const excusal = await createStudentExcusal({
524
+ schoolId: input.schoolId,
525
+ studentIds: uniqueStudentIds,
526
+ startDate: input.startDate,
527
+ endDate: input.endDate,
528
+ reason: input.reason,
529
+ note: input.note,
530
+ createdById: userId,
531
+ });
532
+
533
+ await applyExcusalToAttendance({
534
+ schoolId: input.schoolId,
535
+ excusalId: excusal.id,
536
+ studentIds: uniqueStudentIds,
537
+ startDate: input.startDate,
538
+ endDate: input.endDate,
539
+ updatedById: userId,
540
+ });
541
+
542
+ return excusal;
543
+ }
544
+
545
+ export async function updateExcusal(
546
+ userId: string,
547
+ input: {
548
+ schoolId: string;
549
+ excusalId: string;
550
+ studentIds?: string[];
551
+ startDate?: Date;
552
+ endDate?: Date;
553
+ reason?: string;
554
+ note?: string | null;
555
+ }
556
+ ) {
557
+ await requireSchoolAdmin(input.schoolId, userId);
558
+
559
+ const existing = await findExcusalById(input.excusalId);
560
+ if (!existing || existing.schoolId !== input.schoolId) {
561
+ throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
562
+ }
563
+ if (existing.revokedAt) {
564
+ throw new TRPCError({
565
+ code: "BAD_REQUEST",
566
+ message: "Cannot update a revoked excusal",
567
+ });
568
+ }
569
+
570
+ const startDate = input.startDate ?? existing.startDate;
571
+ const endDate = input.endDate ?? existing.endDate;
572
+ if (toDateOnly(endDate) < toDateOnly(startDate)) {
573
+ throw new TRPCError({
574
+ code: "BAD_REQUEST",
575
+ message: "endDate must be on or after startDate",
576
+ });
577
+ }
578
+
579
+ let studentIds = input.studentIds
580
+ ? [...new Set(input.studentIds)]
581
+ : existing.students.map((s) => s.studentId);
582
+
583
+ if (input.studentIds) {
584
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
585
+ const schoolStudentIds = new Set(memberships.map((m) => m.userId));
586
+ const invalid = studentIds.filter((id) => !schoolStudentIds.has(id));
587
+ if (invalid.length > 0) {
588
+ throw new TRPCError({
589
+ code: "BAD_REQUEST",
590
+ message: "One or more students are not active members of this school",
591
+ });
592
+ }
593
+ }
594
+
595
+ // Clear previous locks, then re-apply for the new window/students.
596
+ await clearExcusalLocks(input.excusalId);
597
+
598
+ const updated = await updateStudentExcusal(input.excusalId, {
599
+ studentIds: input.studentIds ? studentIds : undefined,
600
+ startDate: input.startDate,
601
+ endDate: input.endDate,
602
+ reason: input.reason,
603
+ note: input.note,
604
+ });
605
+
606
+ await applyExcusalToAttendance({
607
+ schoolId: input.schoolId,
608
+ excusalId: updated.id,
609
+ studentIds,
610
+ startDate,
611
+ endDate,
612
+ updatedById: userId,
613
+ });
614
+
113
615
  return updated;
114
616
  }
115
617
 
618
+ export async function revokeExcusal(
619
+ userId: string,
620
+ input: { schoolId: string; excusalId: string }
621
+ ) {
622
+ await requireSchoolAdmin(input.schoolId, userId);
623
+
624
+ const existing = await findExcusalById(input.excusalId);
625
+ if (!existing || existing.schoolId !== input.schoolId) {
626
+ throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
627
+ }
628
+ if (existing.revokedAt) {
629
+ throw new TRPCError({
630
+ code: "BAD_REQUEST",
631
+ message: "Excusal is already revoked",
632
+ });
633
+ }
634
+
635
+ await clearExcusalLocks(input.excusalId);
636
+ return revokeStudentExcusal(input.excusalId, userId);
637
+ }
638
+
116
639
  /** Notify only students whose late or absent status is newly added. */
117
640
  async function notifyMarkedLateOrAbsent(input: {
118
641
  classId: string;
@@ -180,3 +703,6 @@ async function sendAttendanceNotification(input: {
180
703
  logger.error("Failed to send attendance notifications:", { error });
181
704
  }
182
705
  }
706
+
707
+ // Re-export date helpers used by callers/tests.
708
+ export { toDateOnly, startOfUtcDay, endOfUtcDay };