@studious-lms/server 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/.env.example +0 -1
  2. package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
  3. package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
  4. package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
  5. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
  6. package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
  7. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
  8. package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
  9. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
  10. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
  11. package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
  12. package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
  13. package/dist/lib/assignmentTypeDefaults.js +167 -0
  14. package/dist/lib/assignmentTypeDefaults.js.map +1 -0
  15. package/dist/lib/config/env.d.ts +0 -8
  16. package/dist/lib/config/env.d.ts.map +1 -1
  17. package/dist/lib/config/env.js +2 -5
  18. package/dist/lib/config/env.js.map +1 -1
  19. package/dist/lib/feePdf.d.ts +19 -0
  20. package/dist/lib/feePdf.d.ts.map +1 -0
  21. package/dist/lib/feePdf.js +167 -0
  22. package/dist/lib/feePdf.js.map +1 -0
  23. package/dist/lib/feePdfFormat.d.ts +4 -0
  24. package/dist/lib/feePdfFormat.d.ts.map +1 -0
  25. package/dist/lib/feePdfFormat.js +16 -0
  26. package/dist/lib/feePdfFormat.js.map +1 -0
  27. package/dist/lib/idCardPdf.d.ts +26 -0
  28. package/dist/lib/idCardPdf.d.ts.map +1 -0
  29. package/dist/lib/idCardPdf.js +272 -0
  30. package/dist/lib/idCardPdf.js.map +1 -0
  31. package/dist/lib/pdfLetterhead.d.ts +41 -0
  32. package/dist/lib/pdfLetterhead.d.ts.map +1 -0
  33. package/dist/lib/pdfLetterhead.js +86 -0
  34. package/dist/lib/pdfLetterhead.js.map +1 -0
  35. package/dist/lib/transcriptPdf.d.ts +55 -0
  36. package/dist/lib/transcriptPdf.d.ts.map +1 -0
  37. package/dist/lib/transcriptPdf.js +303 -0
  38. package/dist/lib/transcriptPdf.js.map +1 -0
  39. package/dist/models/academicCalendar.d.ts +198 -0
  40. package/dist/models/academicCalendar.d.ts.map +1 -0
  41. package/dist/models/academicCalendar.js +106 -0
  42. package/dist/models/academicCalendar.js.map +1 -0
  43. package/dist/models/agenda.d.ts +8 -0
  44. package/dist/models/agenda.d.ts.map +1 -1
  45. package/dist/models/announcement.d.ts +2 -0
  46. package/dist/models/announcement.d.ts.map +1 -1
  47. package/dist/models/assignment.d.ts +66 -0
  48. package/dist/models/assignment.d.ts.map +1 -1
  49. package/dist/models/attendance.d.ts +659 -21
  50. package/dist/models/attendance.d.ts.map +1 -1
  51. package/dist/models/attendance.js +544 -75
  52. package/dist/models/attendance.js.map +1 -1
  53. package/dist/models/auth.d.ts +2 -0
  54. package/dist/models/auth.d.ts.map +1 -1
  55. package/dist/models/class.d.ts +12 -0
  56. package/dist/models/class.d.ts.map +1 -1
  57. package/dist/models/class.js +3 -2
  58. package/dist/models/class.js.map +1 -1
  59. package/dist/models/event.d.ts +16 -0
  60. package/dist/models/event.d.ts.map +1 -1
  61. package/dist/models/fees.d.ts +554 -0
  62. package/dist/models/fees.d.ts.map +1 -0
  63. package/dist/models/fees.js +278 -0
  64. package/dist/models/fees.js.map +1 -0
  65. package/dist/models/file.d.ts +29 -0
  66. package/dist/models/file.d.ts.map +1 -1
  67. package/dist/models/folder.d.ts +2 -0
  68. package/dist/models/folder.d.ts.map +1 -1
  69. package/dist/models/labChat.d.ts +2 -0
  70. package/dist/models/labChat.d.ts.map +1 -1
  71. package/dist/models/marketplace.d.ts +178 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +115 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +304 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +78 -0
  78. package/dist/models/moduleItem.js.map +1 -0
  79. package/dist/models/newtonChat.d.ts +1 -0
  80. package/dist/models/newtonChat.d.ts.map +1 -1
  81. package/dist/models/school.d.ts +193 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +80 -2
  84. package/dist/models/school.js.map +1 -1
  85. package/dist/models/section.d.ts +4 -0
  86. package/dist/models/section.d.ts.map +1 -1
  87. package/dist/models/worksheet.d.ts +19 -0
  88. package/dist/models/worksheet.d.ts.map +1 -1
  89. package/dist/models/worksheet.js +2 -2
  90. package/dist/models/worksheet.js.map +1 -1
  91. package/dist/routers/_app.d.ts +11605 -7133
  92. package/dist/routers/_app.d.ts.map +1 -1
  93. package/dist/routers/_app.js +12 -2
  94. package/dist/routers/_app.js.map +1 -1
  95. package/dist/routers/academicCalendar.d.ts +229 -0
  96. package/dist/routers/academicCalendar.d.ts.map +1 -0
  97. package/dist/routers/academicCalendar.js +75 -0
  98. package/dist/routers/academicCalendar.js.map +1 -0
  99. package/dist/routers/admin.d.ts +2 -0
  100. package/dist/routers/admin.d.ts.map +1 -1
  101. package/dist/routers/agenda.d.ts +8 -0
  102. package/dist/routers/agenda.d.ts.map +1 -1
  103. package/dist/routers/assignment.d.ts +75 -0
  104. package/dist/routers/assignment.d.ts.map +1 -1
  105. package/dist/routers/assignment.js +10 -2
  106. package/dist/routers/assignment.js.map +1 -1
  107. package/dist/routers/attendance.d.ts +103 -0
  108. package/dist/routers/attendance.d.ts.map +1 -1
  109. package/dist/routers/attendance.js +8 -5
  110. package/dist/routers/attendance.js.map +1 -1
  111. package/dist/routers/class.d.ts +27 -0
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/class.js +6 -2
  114. package/dist/routers/class.js.map +1 -1
  115. package/dist/routers/event.d.ts +8 -0
  116. package/dist/routers/event.d.ts.map +1 -1
  117. package/dist/routers/fees.d.ts +513 -0
  118. package/dist/routers/fees.d.ts.map +1 -0
  119. package/dist/routers/fees.js +88 -0
  120. package/dist/routers/fees.js.map +1 -0
  121. package/dist/routers/file.d.ts +2 -0
  122. package/dist/routers/file.d.ts.map +1 -1
  123. package/dist/routers/gradebook.d.ts +4 -1
  124. package/dist/routers/gradebook.d.ts.map +1 -1
  125. package/dist/routers/marketplace.d.ts +371 -0
  126. package/dist/routers/marketplace.d.ts.map +1 -0
  127. package/dist/routers/marketplace.js +87 -0
  128. package/dist/routers/marketplace.js.map +1 -0
  129. package/dist/routers/moduleItem.d.ts +263 -0
  130. package/dist/routers/moduleItem.d.ts.map +1 -0
  131. package/dist/routers/moduleItem.js +60 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +169 -0
  134. package/dist/routers/parent.d.ts.map +1 -0
  135. package/dist/routers/parent.js +49 -0
  136. package/dist/routers/parent.js.map +1 -0
  137. package/dist/routers/school.d.ts +476 -6
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +130 -4
  140. package/dist/routers/school.js.map +1 -1
  141. package/dist/routers/worksheet.d.ts +27 -0
  142. package/dist/routers/worksheet.d.ts.map +1 -1
  143. package/dist/routers/worksheet.js +10 -3
  144. package/dist/routers/worksheet.js.map +1 -1
  145. package/dist/services/academicCalendar.d.ts +173 -0
  146. package/dist/services/academicCalendar.d.ts.map +1 -0
  147. package/dist/services/academicCalendar.js +214 -0
  148. package/dist/services/academicCalendar.js.map +1 -0
  149. package/dist/services/adminInstitution.d.ts +2 -0
  150. package/dist/services/adminInstitution.d.ts.map +1 -1
  151. package/dist/services/agenda.d.ts +8 -0
  152. package/dist/services/agenda.d.ts.map +1 -1
  153. package/dist/services/assignment.d.ts +75 -0
  154. package/dist/services/assignment.d.ts.map +1 -1
  155. package/dist/services/assignment.js +91 -12
  156. package/dist/services/assignment.js.map +1 -1
  157. package/dist/services/attendance.d.ts +323 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +385 -25
  160. package/dist/services/attendance.js.map +1 -1
  161. package/dist/services/attendanceMerge.d.ts +53 -0
  162. package/dist/services/attendanceMerge.d.ts.map +1 -0
  163. package/dist/services/attendanceMerge.js +87 -0
  164. package/dist/services/attendanceMerge.js.map +1 -0
  165. package/dist/services/attendanceRate.d.ts +24 -0
  166. package/dist/services/attendanceRate.d.ts.map +1 -0
  167. package/dist/services/attendanceRate.js +31 -0
  168. package/dist/services/attendanceRate.js.map +1 -0
  169. package/dist/services/attendanceStats.d.ts +22 -0
  170. package/dist/services/attendanceStats.d.ts.map +1 -0
  171. package/dist/services/attendanceStats.js +45 -0
  172. package/dist/services/attendanceStats.js.map +1 -0
  173. package/dist/services/class.d.ts +27 -0
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +26 -3
  176. package/dist/services/class.js.map +1 -1
  177. package/dist/services/event.d.ts +8 -0
  178. package/dist/services/event.d.ts.map +1 -1
  179. package/dist/services/fees.d.ts +422 -0
  180. package/dist/services/fees.d.ts.map +1 -0
  181. package/dist/services/fees.js +310 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +49 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +52 -0
  186. package/dist/services/feesAuthz.js.map +1 -0
  187. package/dist/services/file.d.ts +4 -0
  188. package/dist/services/file.d.ts.map +1 -1
  189. package/dist/services/gradebook.d.ts +18 -1
  190. package/dist/services/gradebook.d.ts.map +1 -1
  191. package/dist/services/gradebook.js +57 -2
  192. package/dist/services/gradebook.js.map +1 -1
  193. package/dist/services/idCard.d.ts +11 -0
  194. package/dist/services/idCard.d.ts.map +1 -0
  195. package/dist/services/idCard.js +122 -0
  196. package/dist/services/idCard.js.map +1 -0
  197. package/dist/services/marketplace.d.ts +301 -0
  198. package/dist/services/marketplace.d.ts.map +1 -0
  199. package/dist/services/marketplace.js +628 -0
  200. package/dist/services/marketplace.js.map +1 -0
  201. package/dist/services/moduleItem.d.ts +196 -0
  202. package/dist/services/moduleItem.d.ts.map +1 -0
  203. package/dist/services/moduleItem.js +188 -0
  204. package/dist/services/moduleItem.js.map +1 -0
  205. package/dist/services/notification.d.ts +58 -0
  206. package/dist/services/notification.d.ts.map +1 -1
  207. package/dist/services/notification.js +30 -2
  208. package/dist/services/notification.js.map +1 -1
  209. package/dist/services/parentPortal.d.ts +133 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +328 -0
  212. package/dist/services/parentPortal.js.map +1 -0
  213. package/dist/services/school.d.ts +122 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +179 -47
  216. package/dist/services/school.js.map +1 -1
  217. package/dist/services/studentImport.d.ts +34 -0
  218. package/dist/services/studentImport.d.ts.map +1 -0
  219. package/dist/services/studentImport.js +236 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +61 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +243 -0
  224. package/dist/services/transcript.js.map +1 -0
  225. package/dist/services/worksheet.d.ts +22 -0
  226. package/dist/services/worksheet.d.ts.map +1 -1
  227. package/dist/services/worksheet.js +94 -2
  228. package/dist/services/worksheet.js.map +1 -1
  229. package/dist/trpc.d.ts +1 -1
  230. package/dist/trpc.d.ts.map +1 -1
  231. package/dist/trpc.js +5 -5
  232. package/dist/trpc.js.map +1 -1
  233. package/dist/utils/adminPanelAccess.d.ts +4 -5
  234. package/dist/utils/adminPanelAccess.d.ts.map +1 -1
  235. package/dist/utils/adminPanelAccess.js +12 -34
  236. package/dist/utils/adminPanelAccess.js.map +1 -1
  237. package/package.json +2 -1
  238. package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
  239. package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
  240. package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
  241. package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
  242. package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
  243. package/prisma/schema.prisma +371 -11
  244. package/scripts/import-nepal-cdc-curriculum.ts +379 -0
  245. package/src/lib/assignmentTypeDefaults.ts +222 -0
  246. package/src/lib/config/env.ts +0 -3
  247. package/src/lib/feePdf.ts +199 -0
  248. package/src/lib/feePdfFormat.ts +14 -0
  249. package/src/lib/idCardPdf.ts +336 -0
  250. package/src/lib/pdfLetterhead.ts +124 -0
  251. package/src/lib/transcriptPdf.ts +502 -0
  252. package/src/models/academicCalendar.ts +139 -0
  253. package/src/models/attendance.ts +704 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +158 -0
  257. package/src/models/moduleItem.ts +107 -0
  258. package/src/models/school.ts +95 -0
  259. package/src/models/worksheet.ts +2 -0
  260. package/src/routers/_app.ts +10 -0
  261. package/src/routers/academicCalendar.ts +103 -0
  262. package/src/routers/assignment.ts +8 -0
  263. package/src/routers/attendance.ts +7 -3
  264. package/src/routers/class.ts +4 -0
  265. package/src/routers/fees.ts +126 -0
  266. package/src/routers/marketplace.ts +134 -0
  267. package/src/routers/moduleItem.ts +89 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +184 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +299 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +548 -26
  274. package/src/services/attendanceMerge.ts +139 -0
  275. package/src/services/attendanceRate.ts +41 -0
  276. package/src/services/attendanceStats.ts +63 -0
  277. package/src/services/class.ts +25 -1
  278. package/src/services/fees.ts +424 -0
  279. package/src/services/feesAuthz.ts +77 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +785 -0
  283. package/src/services/moduleItem.ts +274 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +380 -0
  286. package/src/services/school.ts +243 -45
  287. package/src/services/studentImport.ts +273 -0
  288. package/src/services/transcript.ts +313 -0
  289. package/src/services/worksheet.ts +111 -0
  290. package/src/trpc.ts +3 -3
  291. package/src/utils/adminPanelAccess.ts +12 -35
  292. package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
  293. package/tests/unit/lib/feePdf.test.ts +15 -0
  294. package/tests/unit/lib/idCardPdf.test.ts +40 -0
  295. package/tests/unit/lib/transcriptPdf.test.ts +111 -0
  296. package/tests/unit/services/attendanceMerge.test.ts +90 -0
  297. package/tests/unit/services/attendanceRate.test.ts +38 -0
  298. package/tests/unit/services/attendanceStats.test.ts +54 -0
@@ -1,13 +1,30 @@
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
 
6
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="703d27e8-ea48-5b60-9620-38757b18337b")}catch(e){}}();
7
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="61646503-b6ff-5d4c-8251-5ecf346dad65")}catch(e){}}();
7
8
  import { TRPCError } from "@trpc/server";
8
- import { findClassWithStudents, findClassWithTeacher, findEventsByClassId, findAttendanceByEventId, findAttendanceByClassAndEvent, findManyAttendance, createAttendance, createAttendanceForEvent, updateAttendance, } from "../models/attendance.js";
9
+ import { findClassWithStudents, findClassWithTeacher, findEventsByClassId, findAttendanceByEventId, findAttendanceByClassAndEvent, findManyAttendance, createAttendance, createAttendanceForEvent, updateAttendance, findActiveExcusalsForStudents, listExcusalsForSchool, findExcusalById, createStudentExcusal, updateStudentExcusal, revokeStudentExcusal, applyExcusalToAttendance, clearExcusalLocks, listSchoolStudentMemberships, listAttendanceRecordsForSchoolStudents, listTodaySchoolAttendanceSessions, toDateOnly, startOfUtcDay, endOfUtcDay, } from "../models/attendance.js";
10
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
9
11
  import { sendToMultiple } from "./notification.js";
10
12
  import { logger } from "../utils/logger.js";
13
+ import { buildExcusalLocks, mergeAttendanceUpdateWithLocks, } from "./attendanceMerge.js";
14
+ import { computeAttendancePercentage, emptyAttendanceCounts, tallyAttendanceStatuses, } from "./attendanceStats.js";
15
+ async function requireSchoolAdmin(schoolId, userId) {
16
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
17
+ if (!membership) {
18
+ throw new TRPCError({
19
+ code: "FORBIDDEN",
20
+ message: "Only school admins can perform this action",
21
+ });
22
+ }
23
+ return membership;
24
+ }
25
+ function sessionDate(attendance) {
26
+ return attendance.event?.startTime ?? attendance.date;
27
+ }
11
28
  /** Get attendance for a class (optionally filtered by event). Creates missing records. */
12
29
  export async function getAttendance(classId, eventId) {
13
30
  const classData = await findClassWithStudents(classId);
@@ -22,16 +39,83 @@ export async function getAttendance(classId, eventId) {
22
39
  for (const event of events) {
23
40
  const existing = await findAttendanceByEventId(event.id);
24
41
  if (!existing) {
42
+ const locks = classData.schoolId && studentIds.length > 0
43
+ ? buildExcusalLocks(await findActiveExcusalsForStudents({
44
+ schoolId: classData.schoolId,
45
+ studentIds,
46
+ date: event.startTime,
47
+ }), studentIds)
48
+ : [];
49
+ const merged = mergeAttendanceUpdateWithLocks({
50
+ present: classData.students.map((s) => ({
51
+ id: s.id,
52
+ username: s.username,
53
+ })),
54
+ late: [],
55
+ absent: [],
56
+ locks,
57
+ });
25
58
  await createAttendanceForEvent({
26
59
  classId,
27
60
  eventId: event.id,
28
- presentIds: studentIds,
61
+ date: event.startTime,
62
+ presentIds: merged.presentIds,
63
+ lateIds: merged.lateIds,
64
+ absentIds: merged.absentIds,
65
+ excusedIds: merged.excusedIds,
66
+ records: merged.records,
29
67
  });
30
68
  }
69
+ else if (classData.schoolId && studentIds.length > 0) {
70
+ // Ensure active excusals are reflected on existing sessions.
71
+ const locks = buildExcusalLocks(await findActiveExcusalsForStudents({
72
+ schoolId: classData.schoolId,
73
+ studentIds,
74
+ date: event.startTime,
75
+ }), studentIds);
76
+ const needsLock = locks.filter((lock) => {
77
+ const current = existing.records.find((r) => r.studentId === lock.studentId);
78
+ return !(current?.status === "EXCUSED" &&
79
+ current.excusedByExcusalId === lock.excusalId);
80
+ });
81
+ const byExcusal = new Map();
82
+ for (const lock of needsLock) {
83
+ const list = byExcusal.get(lock.excusalId) ?? [];
84
+ list.push(lock.studentId);
85
+ byExcusal.set(lock.excusalId, list);
86
+ }
87
+ for (const [excusalId, lockedStudentIds] of byExcusal) {
88
+ await applyExcusalToAttendance({
89
+ schoolId: classData.schoolId,
90
+ excusalId,
91
+ studentIds: lockedStudentIds,
92
+ startDate: event.startTime,
93
+ endDate: event.startTime,
94
+ });
95
+ }
96
+ }
31
97
  }
32
- return findManyAttendance(classId, eventId);
98
+ const sessions = await findManyAttendance(classId, eventId);
99
+ return sessions.map(shapeAttendanceSession);
100
+ }
101
+ /** Additive shape: keep present/late/absent; expose excused + lock metadata for UI. */
102
+ function shapeAttendanceSession(session) {
103
+ const records = session.records.map((r) => ({
104
+ ...r,
105
+ lockedFromExcusal: Boolean(r.excusedByExcusalId),
106
+ reason: r.excusedByExcusal?.reason ?? null,
107
+ excusalNote: r.excusedByExcusal?.note ?? null,
108
+ }));
109
+ const excused = records
110
+ .filter((r) => r.status === "EXCUSED")
111
+ .map((r) => r.student);
112
+ return {
113
+ ...session,
114
+ excused,
115
+ records,
116
+ };
33
117
  }
34
- /** Update attendance record (present/late/absent). Teacher-only. */
118
+ /** Update attendance record (present/late/absent/excused). Teacher-only. */
35
119
  export async function updateAttendanceRecord(userId, classId, eventId, attendance) {
36
120
  const classData = await findClassWithTeacher(classId, userId);
37
121
  if (!classData) {
@@ -41,51 +125,325 @@ export async function updateAttendanceRecord(userId, classId, eventId, attendanc
41
125
  });
42
126
  }
43
127
  const existing = await findAttendanceByClassAndEvent(classId, eventId ?? null);
44
- const presentIds = attendance.present.map((s) => s.id);
45
- const lateIds = attendance.late.map((s) => s.id);
46
- const absentIds = attendance.absent.map((s) => s.id);
128
+ const dateForExcusal = existing
129
+ ? sessionDate(existing)
130
+ : new Date();
131
+ const classStudentIds = classData.students.map((s) => s.id);
132
+ const locks = classData.schoolId && classStudentIds.length > 0
133
+ ? buildExcusalLocks(await findActiveExcusalsForStudents({
134
+ schoolId: classData.schoolId,
135
+ studentIds: classStudentIds,
136
+ date: dateForExcusal,
137
+ }), classStudentIds)
138
+ : [];
139
+ const existingNotes = new Map((existing?.records ?? []).map((r) => [r.studentId, r.note]));
140
+ const merged = mergeAttendanceUpdateWithLocks({
141
+ present: attendance.present,
142
+ late: attendance.late,
143
+ absent: attendance.absent,
144
+ excused: attendance.excused,
145
+ locks,
146
+ updatedById: userId,
147
+ existingNotesByStudentId: existingNotes,
148
+ });
47
149
  const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
48
150
  const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);
49
- const classStudentIds = new Set(classData.students.map((student) => student.id));
151
+ const classStudentIdSet = new Set(classStudentIds);
50
152
  if (!existing) {
51
153
  const created = await createAttendance({
52
154
  classId,
53
155
  eventId: eventId ?? null,
54
- presentIds,
55
- lateIds,
56
- absentIds,
156
+ presentIds: merged.presentIds,
157
+ lateIds: merged.lateIds,
158
+ absentIds: merged.absentIds,
159
+ excusedIds: merged.excusedIds,
160
+ records: merged.records,
161
+ updatedById: userId,
57
162
  });
58
163
  await notifyMarkedLateOrAbsent({
59
164
  classId,
60
165
  className: classData.name,
61
166
  eventId: eventId ?? null,
62
167
  eventName: created.event?.name ?? null,
63
- classStudentIds,
64
- lateIds,
65
- absentIds,
168
+ classStudentIds: classStudentIdSet,
169
+ lateIds: merged.lateIds,
170
+ absentIds: merged.absentIds,
66
171
  previousLateIds,
67
172
  previousAbsentIds,
68
173
  });
69
- return created;
174
+ return shapeAttendanceSession(created);
70
175
  }
71
176
  const updated = await updateAttendance(existing.id, {
72
- presentIds,
73
- lateIds,
74
- absentIds,
177
+ presentIds: merged.presentIds,
178
+ lateIds: merged.lateIds,
179
+ absentIds: merged.absentIds,
180
+ records: merged.records,
75
181
  });
76
182
  await notifyMarkedLateOrAbsent({
77
183
  classId,
78
184
  className: classData.name,
79
185
  eventId: eventId ?? null,
80
186
  eventName: existing.event?.name ?? null,
81
- classStudentIds,
82
- lateIds,
83
- absentIds,
187
+ classStudentIds: classStudentIdSet,
188
+ lateIds: merged.lateIds,
189
+ absentIds: merged.absentIds,
84
190
  previousLateIds,
85
191
  previousAbsentIds,
86
192
  });
193
+ return shapeAttendanceSession(updated);
194
+ }
195
+ /** School-admin: today's attendance rollup across classes. */
196
+ export async function listTodayAttendanceSummary(userId, schoolId) {
197
+ await requireSchoolAdmin(schoolId, userId);
198
+ const today = new Date();
199
+ const sessions = await listTodaySchoolAttendanceSessions(schoolId, today);
200
+ const memberships = await listSchoolStudentMemberships(schoolId);
201
+ const totalStudents = memberships.length;
202
+ const counts = emptyAttendanceCounts();
203
+ const rows = [];
204
+ for (const session of sessions) {
205
+ const tallied = tallyAttendanceStatuses(session.records.map((r) => r.status));
206
+ counts.present += tallied.present;
207
+ counts.late += tallied.late;
208
+ counts.absent += tallied.absent;
209
+ counts.excused += tallied.excused;
210
+ const classStudentCount = session.class._count.students;
211
+ const marked = tallied.present + tallied.late + tallied.absent + tallied.excused;
212
+ rows.push({
213
+ attendanceId: session.id,
214
+ classId: session.classId,
215
+ className: session.class.name,
216
+ section: session.class.section,
217
+ subject: session.class.subject,
218
+ eventId: session.eventId,
219
+ eventName: session.event?.name ?? null,
220
+ date: session.event?.startTime ?? session.date,
221
+ present: tallied.present,
222
+ late: tallied.late,
223
+ absent: tallied.absent,
224
+ excused: tallied.excused,
225
+ unmarked: Math.max(0, classStudentCount - marked),
226
+ classStudentCount,
227
+ });
228
+ }
229
+ return {
230
+ date: toDateOnly(today),
231
+ totalStudents,
232
+ totals: counts,
233
+ percentage: computeAttendancePercentage(counts),
234
+ sessions: rows,
235
+ };
236
+ }
237
+ /** School-admin: per-student attendance counts + % (excused excluded from %). */
238
+ export async function listStudentAttendanceStats(userId, input) {
239
+ await requireSchoolAdmin(input.schoolId, userId);
240
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
241
+ const filtered = input.studentId
242
+ ? memberships.filter((m) => m.userId === input.studentId)
243
+ : memberships;
244
+ const studentIds = filtered.map((m) => m.userId);
245
+ const records = await listAttendanceRecordsForSchoolStudents({
246
+ schoolId: input.schoolId,
247
+ studentIds,
248
+ from: input.from,
249
+ to: input.to,
250
+ });
251
+ const byStudent = new Map();
252
+ for (const id of studentIds) {
253
+ byStudent.set(id, emptyAttendanceCounts());
254
+ }
255
+ for (const record of records) {
256
+ const counts = byStudent.get(record.studentId);
257
+ if (!counts)
258
+ continue;
259
+ switch (record.status) {
260
+ case "PRESENT":
261
+ counts.present += 1;
262
+ break;
263
+ case "LATE":
264
+ counts.late += 1;
265
+ break;
266
+ case "ABSENT":
267
+ counts.absent += 1;
268
+ break;
269
+ case "EXCUSED":
270
+ counts.excused += 1;
271
+ break;
272
+ default:
273
+ break;
274
+ }
275
+ }
276
+ return filtered.map((membership) => {
277
+ const counts = byStudent.get(membership.userId) ?? emptyAttendanceCounts();
278
+ return {
279
+ studentId: membership.userId,
280
+ username: membership.user.username,
281
+ displayName: membership.user.profile?.displayName ?? null,
282
+ profilePicture: membership.user.profile?.profilePicture ?? null,
283
+ profilePictureThumbnail: membership.user.profile?.profilePictureThumbnail ?? null,
284
+ classes: membership.user.studentIn,
285
+ present: counts.present,
286
+ late: counts.late,
287
+ absent: counts.absent,
288
+ excused: counts.excused,
289
+ attendancePercentage: computeAttendancePercentage(counts),
290
+ };
291
+ });
292
+ }
293
+ export async function listExcusals(userId, input) {
294
+ await requireSchoolAdmin(input.schoolId, userId);
295
+ const excusals = await listExcusalsForSchool(input);
296
+ const now = toDateOnly(new Date());
297
+ return excusals.map((excusal) => {
298
+ let status = "ACTIVE";
299
+ if (excusal.revokedAt) {
300
+ status = "REVOKED";
301
+ }
302
+ else if (toDateOnly(excusal.startDate) > now) {
303
+ status = "UPCOMING";
304
+ }
305
+ else if (toDateOnly(excusal.endDate) < now) {
306
+ status = "ACTIVE"; // past but not revoked — still listed as ACTIVE historically
307
+ }
308
+ return {
309
+ id: excusal.id,
310
+ schoolId: excusal.schoolId,
311
+ startDate: excusal.startDate,
312
+ endDate: excusal.endDate,
313
+ reason: excusal.reason,
314
+ note: excusal.note,
315
+ revokedAt: excusal.revokedAt,
316
+ createdAt: excusal.createdAt,
317
+ updatedAt: excusal.updatedAt,
318
+ status,
319
+ studentIds: excusal.students.map((s) => s.studentId),
320
+ students: excusal.students.map((s) => ({
321
+ id: s.student.id,
322
+ username: s.student.username,
323
+ displayName: s.student.profile?.displayName ?? null,
324
+ profilePicture: s.student.profile?.profilePicture ?? null,
325
+ profilePictureThumbnail: s.student.profile?.profilePictureThumbnail ?? null,
326
+ })),
327
+ createdBy: {
328
+ id: excusal.createdBy.id,
329
+ username: excusal.createdBy.username,
330
+ displayName: excusal.createdBy.profile?.displayName ?? null,
331
+ },
332
+ revokedBy: excusal.revokedBy
333
+ ? {
334
+ id: excusal.revokedBy.id,
335
+ username: excusal.revokedBy.username,
336
+ displayName: excusal.revokedBy.profile?.displayName ?? null,
337
+ }
338
+ : null,
339
+ };
340
+ });
341
+ }
342
+ export async function createExcusal(userId, input) {
343
+ await requireSchoolAdmin(input.schoolId, userId);
344
+ if (toDateOnly(input.endDate) < toDateOnly(input.startDate)) {
345
+ throw new TRPCError({
346
+ code: "BAD_REQUEST",
347
+ message: "endDate must be on or after startDate",
348
+ });
349
+ }
350
+ const uniqueStudentIds = [...new Set(input.studentIds)];
351
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
352
+ const schoolStudentIds = new Set(memberships.map((m) => m.userId));
353
+ const invalid = uniqueStudentIds.filter((id) => !schoolStudentIds.has(id));
354
+ if (invalid.length > 0) {
355
+ throw new TRPCError({
356
+ code: "BAD_REQUEST",
357
+ message: "One or more students are not active members of this school",
358
+ });
359
+ }
360
+ const excusal = await createStudentExcusal({
361
+ schoolId: input.schoolId,
362
+ studentIds: uniqueStudentIds,
363
+ startDate: input.startDate,
364
+ endDate: input.endDate,
365
+ reason: input.reason,
366
+ note: input.note,
367
+ createdById: userId,
368
+ });
369
+ await applyExcusalToAttendance({
370
+ schoolId: input.schoolId,
371
+ excusalId: excusal.id,
372
+ studentIds: uniqueStudentIds,
373
+ startDate: input.startDate,
374
+ endDate: input.endDate,
375
+ updatedById: userId,
376
+ });
377
+ return excusal;
378
+ }
379
+ export async function updateExcusal(userId, input) {
380
+ await requireSchoolAdmin(input.schoolId, userId);
381
+ const existing = await findExcusalById(input.excusalId);
382
+ if (!existing || existing.schoolId !== input.schoolId) {
383
+ throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
384
+ }
385
+ if (existing.revokedAt) {
386
+ throw new TRPCError({
387
+ code: "BAD_REQUEST",
388
+ message: "Cannot update a revoked excusal",
389
+ });
390
+ }
391
+ const startDate = input.startDate ?? existing.startDate;
392
+ const endDate = input.endDate ?? existing.endDate;
393
+ if (toDateOnly(endDate) < toDateOnly(startDate)) {
394
+ throw new TRPCError({
395
+ code: "BAD_REQUEST",
396
+ message: "endDate must be on or after startDate",
397
+ });
398
+ }
399
+ let studentIds = input.studentIds
400
+ ? [...new Set(input.studentIds)]
401
+ : existing.students.map((s) => s.studentId);
402
+ if (input.studentIds) {
403
+ const memberships = await listSchoolStudentMemberships(input.schoolId);
404
+ const schoolStudentIds = new Set(memberships.map((m) => m.userId));
405
+ const invalid = studentIds.filter((id) => !schoolStudentIds.has(id));
406
+ if (invalid.length > 0) {
407
+ throw new TRPCError({
408
+ code: "BAD_REQUEST",
409
+ message: "One or more students are not active members of this school",
410
+ });
411
+ }
412
+ }
413
+ // Clear previous locks, then re-apply for the new window/students.
414
+ await clearExcusalLocks(input.excusalId);
415
+ const updated = await updateStudentExcusal(input.excusalId, {
416
+ studentIds: input.studentIds ? studentIds : undefined,
417
+ startDate: input.startDate,
418
+ endDate: input.endDate,
419
+ reason: input.reason,
420
+ note: input.note,
421
+ });
422
+ await applyExcusalToAttendance({
423
+ schoolId: input.schoolId,
424
+ excusalId: updated.id,
425
+ studentIds,
426
+ startDate,
427
+ endDate,
428
+ updatedById: userId,
429
+ });
87
430
  return updated;
88
431
  }
432
+ export async function revokeExcusal(userId, input) {
433
+ await requireSchoolAdmin(input.schoolId, userId);
434
+ const existing = await findExcusalById(input.excusalId);
435
+ if (!existing || existing.schoolId !== input.schoolId) {
436
+ throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
437
+ }
438
+ if (existing.revokedAt) {
439
+ throw new TRPCError({
440
+ code: "BAD_REQUEST",
441
+ message: "Excusal is already revoked",
442
+ });
443
+ }
444
+ await clearExcusalLocks(input.excusalId);
445
+ return revokeStudentExcusal(input.excusalId, userId);
446
+ }
89
447
  /** Notify only students whose late or absent status is newly added. */
90
448
  async function notifyMarkedLateOrAbsent(input) {
91
449
  const lateReceiverIds = [...new Set(input.lateIds)].filter((id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id));
@@ -129,5 +487,7 @@ async function sendAttendanceNotification(input) {
129
487
  logger.error("Failed to send attendance notifications:", { error });
130
488
  }
131
489
  }
490
+ // Re-export date helpers used by callers/tests.
491
+ export { toDateOnly, startOfUtcDay, endOfUtcDay };
132
492
  //# sourceMappingURL=attendance.js.map
133
- //# debugId=703d27e8-ea48-5b60-9620-38757b18337b
493
+ //# debugId=61646503-b6ff-5d4c-8251-5ecf346dad65
@@ -1 +1 @@
1
- {"version":3,"file":"attendance.js","sources":["services/attendance.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Attendance service – get and update class attendance records.\n * Auto-creates attendance records for events that don't have them yet.\n */\nimport { TRPCError } from \"@trpc/server\";\nimport {\n findClassWithStudents,\n findClassWithTeacher,\n findEventsByClassId,\n findAttendanceByEventId,\n findAttendanceByClassAndEvent,\n findManyAttendance,\n createAttendance,\n createAttendanceForEvent,\n updateAttendance,\n} from \"../models/attendance.js\";\nimport { sendToMultiple } from \"./notification.js\";\nimport { logger } from \"../utils/logger.js\";\n\n/** Get attendance for a class (optionally filtered by event). Creates missing records. */\nexport async function getAttendance(classId: string, eventId?: string) {\n const classData = await findClassWithStudents(classId);\n if (!classData) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: \"You are not authorized to view this class's attendance\",\n });\n }\n\n const events = await findEventsByClassId(classId);\n const studentIds = classData.students.map((s) => s.id);\n\n for (const event of events) {\n const existing = await findAttendanceByEventId(event.id);\n if (!existing) {\n await createAttendanceForEvent({\n classId,\n eventId: event.id,\n presentIds: studentIds,\n });\n }\n }\n\n return findManyAttendance(classId, eventId);\n}\n\n/** Update attendance record (present/late/absent). Teacher-only. */\nexport async function updateAttendanceRecord(\n userId: string,\n classId: string,\n eventId: string | null | undefined,\n attendance: {\n present: { id: string; username: string }[];\n late: { id: string; username: string }[];\n absent: { id: string; username: string }[];\n }\n) {\n const classData = await findClassWithTeacher(classId, userId);\n if (!classData) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: \"You are not authorized to update this class's attendance\",\n });\n }\n\n const existing = await findAttendanceByClassAndEvent(classId, eventId ?? null);\n\n const presentIds = attendance.present.map((s) => s.id);\n const lateIds = attendance.late.map((s) => s.id);\n const absentIds = attendance.absent.map((s) => s.id);\n const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);\n const previousAbsentIds = new Set(existing?.absent.map((student) => student.id) ?? []);\n const classStudentIds = new Set(classData.students.map((student) => student.id));\n\n if (!existing) {\n const created = await createAttendance({\n classId,\n eventId: eventId ?? null,\n presentIds,\n lateIds,\n absentIds,\n });\n await notifyMarkedLateOrAbsent({\n classId,\n className: classData.name,\n eventId: eventId ?? null,\n eventName: created.event?.name ?? null,\n classStudentIds,\n lateIds,\n absentIds,\n previousLateIds,\n previousAbsentIds,\n });\n return created;\n }\n\n const updated = await updateAttendance(existing.id, {\n presentIds,\n lateIds,\n absentIds,\n });\n await notifyMarkedLateOrAbsent({\n classId,\n className: classData.name,\n eventId: eventId ?? null,\n eventName: existing.event?.name ?? null,\n classStudentIds,\n lateIds,\n absentIds,\n previousLateIds,\n previousAbsentIds,\n });\n return updated;\n}\n\n/** Notify only students whose late or absent status is newly added. */\nasync function notifyMarkedLateOrAbsent(input: {\n classId: string;\n className: string;\n eventId: string | null;\n eventName: string | null;\n classStudentIds: Set<string>;\n lateIds: string[];\n absentIds: string[];\n previousLateIds: Set<string>;\n previousAbsentIds: Set<string>;\n}) {\n const lateReceiverIds = [...new Set(input.lateIds)].filter(\n (id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id)\n );\n const absentReceiverIds = [...new Set(input.absentIds)].filter(\n (id) => input.classStudentIds.has(id) && !input.previousAbsentIds.has(id)\n );\n const link = input.eventId\n ? `/class/${input.classId}/attendance/${input.eventId}`\n : `/class/${input.classId}/attendance`;\n const attendanceLabel = input.eventName\n ? `\"${input.eventName}\" in ${input.className}`\n : input.className;\n\n await Promise.all([\n sendAttendanceNotification({\n receiverIds: lateReceiverIds,\n title: `Marked late in ${input.className}`,\n content: `You were marked late for ${attendanceLabel}.\\n[View attendance](${link})`,\n type: \"ATTENDANCE\",\n entityType: \"ATTENDANCE\",\n entityId: input.eventId,\n classId: input.classId,\n }),\n sendAttendanceNotification({\n receiverIds: absentReceiverIds,\n title: `Marked absent in ${input.className}`,\n content: `You were marked absent for ${attendanceLabel}.\\n[View attendance](${link})`,\n type: \"ATTENDANCE\",\n entityType: \"ATTENDANCE\",\n entityId: input.eventId,\n classId: input.classId,\n }),\n ]);\n}\n\n/** Send attendance notifications without failing the attendance update itself. */\nasync function sendAttendanceNotification(input: {\n receiverIds: string[];\n title: string;\n content: string;\n type: \"ATTENDANCE\";\n entityType: string;\n entityId: string | null;\n classId: string;\n}) {\n if (input.receiverIds.length === 0) {\n return;\n }\n\n try {\n await sendToMultiple(input);\n } catch (error) {\n logger.error(\"Failed to send attendance notifications:\", { error });\n }\n}\n"],"names":[],"mappings":"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","debug_id":"703d27e8-ea48-5b60-9620-38757b18337b"}
1
+ {"version":3,"file":"attendance.js","sources":["services/attendance.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Attendance service – class attendance + school-admin excusals / stats.\n * Dual-writes legacy present/late/absent lists and per-student AttendanceRecord.\n * Active StudentExcusal rows force EXCUSED (locked for teachers).\n */\nimport { TRPCError } from \"@trpc/server\";\nimport {\n findClassWithStudents,\n findClassWithTeacher,\n findEventsByClassId,\n findAttendanceByEventId,\n findAttendanceByClassAndEvent,\n findManyAttendance,\n createAttendance,\n createAttendanceForEvent,\n updateAttendance,\n findActiveExcusalsForStudents,\n listExcusalsForSchool,\n findExcusalById,\n createStudentExcusal,\n updateStudentExcusal,\n revokeStudentExcusal,\n applyExcusalToAttendance,\n clearExcusalLocks,\n listSchoolStudentMemberships,\n listAttendanceRecordsForSchoolStudents,\n listTodaySchoolAttendanceSessions,\n toDateOnly,\n startOfUtcDay,\n endOfUtcDay,\n} from \"../models/attendance.js\";\nimport { findActiveSchoolAdminMembership } from \"../models/school.js\";\nimport { sendToMultiple } from \"./notification.js\";\nimport { logger } from \"../utils/logger.js\";\nimport {\n buildExcusalLocks,\n mergeAttendanceUpdateWithLocks,\n type StudentRef,\n} from \"./attendanceMerge.js\";\nimport {\n computeAttendancePercentage,\n emptyAttendanceCounts,\n tallyAttendanceStatuses,\n} from \"./attendanceStats.js\";\n\nasync function requireSchoolAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can perform this action\",\n });\n }\n return membership;\n}\n\nfunction sessionDate(attendance: {\n date: Date;\n event?: { startTime: Date } | null;\n}): Date {\n return attendance.event?.startTime ?? attendance.date;\n}\n\n/** Get attendance for a class (optionally filtered by event). Creates missing records. */\nexport async function getAttendance(classId: string, eventId?: string) {\n const classData = await findClassWithStudents(classId);\n if (!classData) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: \"You are not authorized to view this class's attendance\",\n });\n }\n\n const events = await findEventsByClassId(classId);\n const studentIds = classData.students.map((s) => s.id);\n\n for (const event of events) {\n const existing = await findAttendanceByEventId(event.id);\n if (!existing) {\n const locks =\n classData.schoolId && studentIds.length > 0\n ? buildExcusalLocks(\n await findActiveExcusalsForStudents({\n schoolId: classData.schoolId,\n studentIds,\n date: event.startTime,\n }),\n studentIds\n )\n : [];\n\n const merged = mergeAttendanceUpdateWithLocks({\n present: classData.students.map((s) => ({\n id: s.id,\n username: s.username,\n })),\n late: [],\n absent: [],\n locks,\n });\n\n await createAttendanceForEvent({\n classId,\n eventId: event.id,\n date: event.startTime,\n presentIds: merged.presentIds,\n lateIds: merged.lateIds,\n absentIds: merged.absentIds,\n excusedIds: merged.excusedIds,\n records: merged.records,\n });\n } else if (classData.schoolId && studentIds.length > 0) {\n // Ensure active excusals are reflected on existing sessions.\n const locks = buildExcusalLocks(\n await findActiveExcusalsForStudents({\n schoolId: classData.schoolId,\n studentIds,\n date: event.startTime,\n }),\n studentIds\n );\n const needsLock = locks.filter((lock) => {\n const current = existing.records.find((r) => r.studentId === lock.studentId);\n return !(\n current?.status === \"EXCUSED\" &&\n current.excusedByExcusalId === lock.excusalId\n );\n });\n const byExcusal = new Map<string, string[]>();\n for (const lock of needsLock) {\n const list = byExcusal.get(lock.excusalId) ?? [];\n list.push(lock.studentId);\n byExcusal.set(lock.excusalId, list);\n }\n for (const [excusalId, lockedStudentIds] of byExcusal) {\n await applyExcusalToAttendance({\n schoolId: classData.schoolId,\n excusalId,\n studentIds: lockedStudentIds,\n startDate: event.startTime,\n endDate: event.startTime,\n });\n }\n }\n }\n\n const sessions = await findManyAttendance(classId, eventId);\n return sessions.map(shapeAttendanceSession);\n}\n\n/** Additive shape: keep present/late/absent; expose excused + lock metadata for UI. */\nfunction shapeAttendanceSession<\n T extends {\n records: Array<{\n id: string;\n studentId: string;\n status: string;\n note: string | null;\n excusedByExcusalId: string | null;\n updatedById: string | null;\n updatedAt: Date;\n student: { id: string; username: string };\n excusedByExcusal: {\n id: string;\n reason: string;\n note: string | null;\n } | null;\n }>;\n present: unknown[];\n late: unknown[];\n absent: unknown[];\n },\n>(session: T) {\n const records = session.records.map((r) => ({\n ...r,\n lockedFromExcusal: Boolean(r.excusedByExcusalId),\n reason: r.excusedByExcusal?.reason ?? null,\n excusalNote: r.excusedByExcusal?.note ?? null,\n }));\n const excused = records\n .filter((r) => r.status === \"EXCUSED\")\n .map((r) => r.student);\n\n return {\n ...session,\n excused,\n records,\n };\n}\n\n/** Update attendance record (present/late/absent/excused). Teacher-only. */\nexport async function updateAttendanceRecord(\n userId: string,\n classId: string,\n eventId: string | null | undefined,\n attendance: {\n present: StudentRef[];\n late: StudentRef[];\n absent: StudentRef[];\n excused?: StudentRef[];\n }\n) {\n const classData = await findClassWithTeacher(classId, userId);\n if (!classData) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: \"You are not authorized to update this class's attendance\",\n });\n }\n\n const existing = await findAttendanceByClassAndEvent(classId, eventId ?? null);\n const dateForExcusal = existing\n ? sessionDate(existing)\n : new Date();\n\n const classStudentIds = classData.students.map((s) => s.id);\n const locks =\n classData.schoolId && classStudentIds.length > 0\n ? buildExcusalLocks(\n await findActiveExcusalsForStudents({\n schoolId: classData.schoolId,\n studentIds: classStudentIds,\n date: dateForExcusal,\n }),\n classStudentIds\n )\n : [];\n\n const existingNotes = new Map(\n (existing?.records ?? []).map((r) => [r.studentId, r.note])\n );\n\n const merged = mergeAttendanceUpdateWithLocks({\n present: attendance.present,\n late: attendance.late,\n absent: attendance.absent,\n excused: attendance.excused,\n locks,\n updatedById: userId,\n existingNotesByStudentId: existingNotes,\n });\n\n const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);\n const previousAbsentIds = new Set(\n existing?.absent.map((student) => student.id) ?? []\n );\n const classStudentIdSet = new Set(classStudentIds);\n\n if (!existing) {\n const created = await createAttendance({\n classId,\n eventId: eventId ?? null,\n presentIds: merged.presentIds,\n lateIds: merged.lateIds,\n absentIds: merged.absentIds,\n excusedIds: merged.excusedIds,\n records: merged.records,\n updatedById: userId,\n });\n await notifyMarkedLateOrAbsent({\n classId,\n className: classData.name,\n eventId: eventId ?? null,\n eventName: created.event?.name ?? null,\n classStudentIds: classStudentIdSet,\n lateIds: merged.lateIds,\n absentIds: merged.absentIds,\n previousLateIds,\n previousAbsentIds,\n });\n return shapeAttendanceSession(created);\n }\n\n const updated = await updateAttendance(existing.id, {\n presentIds: merged.presentIds,\n lateIds: merged.lateIds,\n absentIds: merged.absentIds,\n records: merged.records,\n });\n await notifyMarkedLateOrAbsent({\n classId,\n className: classData.name,\n eventId: eventId ?? null,\n eventName: existing.event?.name ?? null,\n classStudentIds: classStudentIdSet,\n lateIds: merged.lateIds,\n absentIds: merged.absentIds,\n previousLateIds,\n previousAbsentIds,\n });\n return shapeAttendanceSession(updated);\n}\n\n/** School-admin: today's attendance rollup across classes. */\nexport async function listTodayAttendanceSummary(\n userId: string,\n schoolId: string\n) {\n await requireSchoolAdmin(schoolId, userId);\n\n const today = new Date();\n const sessions = await listTodaySchoolAttendanceSessions(schoolId, today);\n const memberships = await listSchoolStudentMemberships(schoolId);\n const totalStudents = memberships.length;\n\n const counts = emptyAttendanceCounts();\n const rows: Array<{\n attendanceId: string;\n classId: string;\n className: string;\n section: string;\n subject: string;\n eventId: string | null;\n eventName: string | null;\n date: Date;\n present: number;\n late: number;\n absent: number;\n excused: number;\n unmarked: number;\n classStudentCount: number;\n }> = [];\n\n for (const session of sessions) {\n const tallied = tallyAttendanceStatuses(session.records.map((r) => r.status));\n counts.present += tallied.present;\n counts.late += tallied.late;\n counts.absent += tallied.absent;\n counts.excused += tallied.excused;\n const classStudentCount = session.class._count.students;\n const marked =\n tallied.present + tallied.late + tallied.absent + tallied.excused;\n rows.push({\n attendanceId: session.id,\n classId: session.classId,\n className: session.class.name,\n section: session.class.section,\n subject: session.class.subject,\n eventId: session.eventId,\n eventName: session.event?.name ?? null,\n date: session.event?.startTime ?? session.date,\n present: tallied.present,\n late: tallied.late,\n absent: tallied.absent,\n excused: tallied.excused,\n unmarked: Math.max(0, classStudentCount - marked),\n classStudentCount,\n });\n }\n\n return {\n date: toDateOnly(today),\n totalStudents,\n totals: counts,\n percentage: computeAttendancePercentage(counts),\n sessions: rows,\n };\n}\n\n/** School-admin: per-student attendance counts + % (excused excluded from %). */\nexport async function listStudentAttendanceStats(\n userId: string,\n input: {\n schoolId: string;\n from?: Date;\n to?: Date;\n studentId?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const memberships = await listSchoolStudentMemberships(input.schoolId);\n const filtered = input.studentId\n ? memberships.filter((m) => m.userId === input.studentId)\n : memberships;\n const studentIds = filtered.map((m) => m.userId);\n\n const records = await listAttendanceRecordsForSchoolStudents({\n schoolId: input.schoolId,\n studentIds,\n from: input.from,\n to: input.to,\n });\n\n const byStudent = new Map<string, ReturnType<typeof emptyAttendanceCounts>>();\n for (const id of studentIds) {\n byStudent.set(id, emptyAttendanceCounts());\n }\n for (const record of records) {\n const counts = byStudent.get(record.studentId);\n if (!counts) continue;\n switch (record.status) {\n case \"PRESENT\":\n counts.present += 1;\n break;\n case \"LATE\":\n counts.late += 1;\n break;\n case \"ABSENT\":\n counts.absent += 1;\n break;\n case \"EXCUSED\":\n counts.excused += 1;\n break;\n default:\n break;\n }\n }\n\n return filtered.map((membership) => {\n const counts = byStudent.get(membership.userId) ?? emptyAttendanceCounts();\n return {\n studentId: membership.userId,\n username: membership.user.username,\n displayName: membership.user.profile?.displayName ?? null,\n profilePicture: membership.user.profile?.profilePicture ?? null,\n profilePictureThumbnail:\n membership.user.profile?.profilePictureThumbnail ?? null,\n classes: membership.user.studentIn,\n present: counts.present,\n late: counts.late,\n absent: counts.absent,\n excused: counts.excused,\n attendancePercentage: computeAttendancePercentage(counts),\n };\n });\n}\n\nexport async function listExcusals(\n userId: string,\n input: {\n schoolId: string;\n includeRevoked?: boolean;\n studentId?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const excusals = await listExcusalsForSchool(input);\n const now = toDateOnly(new Date());\n\n return excusals.map((excusal) => {\n let status: \"ACTIVE\" | \"UPCOMING\" | \"REVOKED\" = \"ACTIVE\";\n if (excusal.revokedAt) {\n status = \"REVOKED\";\n } else if (toDateOnly(excusal.startDate) > now) {\n status = \"UPCOMING\";\n } else if (toDateOnly(excusal.endDate) < now) {\n status = \"ACTIVE\"; // past but not revoked — still listed as ACTIVE historically\n }\n\n return {\n id: excusal.id,\n schoolId: excusal.schoolId,\n startDate: excusal.startDate,\n endDate: excusal.endDate,\n reason: excusal.reason,\n note: excusal.note,\n revokedAt: excusal.revokedAt,\n createdAt: excusal.createdAt,\n updatedAt: excusal.updatedAt,\n status,\n studentIds: excusal.students.map((s) => s.studentId),\n students: excusal.students.map((s) => ({\n id: s.student.id,\n username: s.student.username,\n displayName: s.student.profile?.displayName ?? null,\n profilePicture: s.student.profile?.profilePicture ?? null,\n profilePictureThumbnail:\n s.student.profile?.profilePictureThumbnail ?? null,\n })),\n createdBy: {\n id: excusal.createdBy.id,\n username: excusal.createdBy.username,\n displayName: excusal.createdBy.profile?.displayName ?? null,\n },\n revokedBy: excusal.revokedBy\n ? {\n id: excusal.revokedBy.id,\n username: excusal.revokedBy.username,\n displayName: excusal.revokedBy.profile?.displayName ?? null,\n }\n : null,\n };\n });\n}\n\nexport async function createExcusal(\n userId: string,\n input: {\n schoolId: string;\n studentIds: string[];\n startDate: Date;\n endDate: Date;\n reason: string;\n note?: string | null;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n if (toDateOnly(input.endDate) < toDateOnly(input.startDate)) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"endDate must be on or after startDate\",\n });\n }\n\n const uniqueStudentIds = [...new Set(input.studentIds)];\n const memberships = await listSchoolStudentMemberships(input.schoolId);\n const schoolStudentIds = new Set(memberships.map((m) => m.userId));\n const invalid = uniqueStudentIds.filter((id) => !schoolStudentIds.has(id));\n if (invalid.length > 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"One or more students are not active members of this school\",\n });\n }\n\n const excusal = await createStudentExcusal({\n schoolId: input.schoolId,\n studentIds: uniqueStudentIds,\n startDate: input.startDate,\n endDate: input.endDate,\n reason: input.reason,\n note: input.note,\n createdById: userId,\n });\n\n await applyExcusalToAttendance({\n schoolId: input.schoolId,\n excusalId: excusal.id,\n studentIds: uniqueStudentIds,\n startDate: input.startDate,\n endDate: input.endDate,\n updatedById: userId,\n });\n\n return excusal;\n}\n\nexport async function updateExcusal(\n userId: string,\n input: {\n schoolId: string;\n excusalId: string;\n studentIds?: string[];\n startDate?: Date;\n endDate?: Date;\n reason?: string;\n note?: string | null;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const existing = await findExcusalById(input.excusalId);\n if (!existing || existing.schoolId !== input.schoolId) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Excusal not found\" });\n }\n if (existing.revokedAt) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Cannot update a revoked excusal\",\n });\n }\n\n const startDate = input.startDate ?? existing.startDate;\n const endDate = input.endDate ?? existing.endDate;\n if (toDateOnly(endDate) < toDateOnly(startDate)) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"endDate must be on or after startDate\",\n });\n }\n\n let studentIds = input.studentIds\n ? [...new Set(input.studentIds)]\n : existing.students.map((s) => s.studentId);\n\n if (input.studentIds) {\n const memberships = await listSchoolStudentMemberships(input.schoolId);\n const schoolStudentIds = new Set(memberships.map((m) => m.userId));\n const invalid = studentIds.filter((id) => !schoolStudentIds.has(id));\n if (invalid.length > 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"One or more students are not active members of this school\",\n });\n }\n }\n\n // Clear previous locks, then re-apply for the new window/students.\n await clearExcusalLocks(input.excusalId);\n\n const updated = await updateStudentExcusal(input.excusalId, {\n studentIds: input.studentIds ? studentIds : undefined,\n startDate: input.startDate,\n endDate: input.endDate,\n reason: input.reason,\n note: input.note,\n });\n\n await applyExcusalToAttendance({\n schoolId: input.schoolId,\n excusalId: updated.id,\n studentIds,\n startDate,\n endDate,\n updatedById: userId,\n });\n\n return updated;\n}\n\nexport async function revokeExcusal(\n userId: string,\n input: { schoolId: string; excusalId: string }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const existing = await findExcusalById(input.excusalId);\n if (!existing || existing.schoolId !== input.schoolId) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Excusal not found\" });\n }\n if (existing.revokedAt) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Excusal is already revoked\",\n });\n }\n\n await clearExcusalLocks(input.excusalId);\n return revokeStudentExcusal(input.excusalId, userId);\n}\n\n/** Notify only students whose late or absent status is newly added. */\nasync function notifyMarkedLateOrAbsent(input: {\n classId: string;\n className: string;\n eventId: string | null;\n eventName: string | null;\n classStudentIds: Set<string>;\n lateIds: string[];\n absentIds: string[];\n previousLateIds: Set<string>;\n previousAbsentIds: Set<string>;\n}) {\n const lateReceiverIds = [...new Set(input.lateIds)].filter(\n (id) => input.classStudentIds.has(id) && !input.previousLateIds.has(id)\n );\n const absentReceiverIds = [...new Set(input.absentIds)].filter(\n (id) => input.classStudentIds.has(id) && !input.previousAbsentIds.has(id)\n );\n const link = input.eventId\n ? `/class/${input.classId}/attendance/${input.eventId}`\n : `/class/${input.classId}/attendance`;\n const attendanceLabel = input.eventName\n ? `\"${input.eventName}\" in ${input.className}`\n : input.className;\n\n await Promise.all([\n sendAttendanceNotification({\n receiverIds: lateReceiverIds,\n title: `Marked late in ${input.className}`,\n content: `You were marked late for ${attendanceLabel}.\\n[View attendance](${link})`,\n type: \"ATTENDANCE\",\n entityType: \"ATTENDANCE\",\n entityId: input.eventId,\n classId: input.classId,\n }),\n sendAttendanceNotification({\n receiverIds: absentReceiverIds,\n title: `Marked absent in ${input.className}`,\n content: `You were marked absent for ${attendanceLabel}.\\n[View attendance](${link})`,\n type: \"ATTENDANCE\",\n entityType: \"ATTENDANCE\",\n entityId: input.eventId,\n classId: input.classId,\n }),\n ]);\n}\n\n/** Send attendance notifications without failing the attendance update itself. */\nasync function sendAttendanceNotification(input: {\n receiverIds: string[];\n title: string;\n content: string;\n type: \"ATTENDANCE\";\n entityType: string;\n entityId: string | null;\n classId: string;\n}) {\n if (input.receiverIds.length === 0) {\n return;\n }\n\n try {\n await sendToMultiple(input);\n } catch (error) {\n logger.error(\"Failed to send attendance notifications:\", { error });\n }\n}\n\n// Re-export date helpers used by callers/tests.\nexport { toDateOnly, startOfUtcDay, endOfUtcDay };\n"],"names":[],"mappings":"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","debug_id":"61646503-b6ff-5d4c-8251-5ecf346dad65"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure helpers for merging teacher attendance updates with excusal locks.
3
+ */
4
+ import type { AttendanceStatus } from "@prisma/client";
5
+ export type StudentRef = {
6
+ id: string;
7
+ username: string;
8
+ };
9
+ export type AttendanceLock = {
10
+ studentId: string;
11
+ excusalId: string;
12
+ reason?: string | null;
13
+ note?: string | null;
14
+ };
15
+ export type MergedAttendanceUpdate = {
16
+ presentIds: string[];
17
+ lateIds: string[];
18
+ absentIds: string[];
19
+ excusedIds: string[];
20
+ records: Array<{
21
+ studentId: string;
22
+ status: AttendanceStatus;
23
+ note?: string | null;
24
+ excusedByExcusalId?: string | null;
25
+ updatedById?: string | null;
26
+ }>;
27
+ /** Students whose status was forced to EXCUSED by an active excusal. */
28
+ lockedStudentIds: string[];
29
+ };
30
+ /**
31
+ * Build present/late/absent/excused lists + per-student records.
32
+ * Active excusal locks always win (status EXCUSED + excusedByExcusalId set).
33
+ */
34
+ export declare function mergeAttendanceUpdateWithLocks(input: {
35
+ present: StudentRef[];
36
+ late: StudentRef[];
37
+ absent: StudentRef[];
38
+ excused?: StudentRef[];
39
+ locks: AttendanceLock[];
40
+ updatedById?: string | null;
41
+ /** Preserve notes for locked students when available. */
42
+ existingNotesByStudentId?: Map<string, string | null | undefined>;
43
+ }): MergedAttendanceUpdate;
44
+ /** Map active excusals (with student join rows) to per-student locks. */
45
+ export declare function buildExcusalLocks(excusals: Array<{
46
+ id: string;
47
+ reason?: string | null;
48
+ note?: string | null;
49
+ students: Array<{
50
+ studentId: string;
51
+ }>;
52
+ }>, studentIds?: Iterable<string>): AttendanceLock[];
53
+ //# sourceMappingURL=attendanceMerge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attendanceMerge.d.ts","sourceRoot":"/","sources":["services/attendanceMerge.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,EAAE,KAAK,CAAC;QACb,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,gBAAgB,CAAC;QACzB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACnC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAC7B,CAAC,CAAC;IACH,wEAAwE;IACxE,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE;IACpD,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,IAAI,EAAE,UAAU,EAAE,CAAC;IACnB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,EAAE,UAAU,EAAE,CAAC;IACvB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,yDAAyD;IACzD,wBAAwB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnE,GAAG,sBAAsB,CA+DzB;AAED,yEAAyE;AACzE,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,KAAK,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxC,CAAC,EACF,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,GAC5B,cAAc,EAAE,CAqBlB"}