@studious-lms/server 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/.env.example +0 -1
  2. package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
  3. package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
  4. package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
  5. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
  6. package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
  7. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
  8. package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
  9. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
  10. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
  11. package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
  12. package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
  13. package/dist/lib/assignmentTypeDefaults.js +167 -0
  14. package/dist/lib/assignmentTypeDefaults.js.map +1 -0
  15. package/dist/lib/config/env.d.ts +0 -8
  16. package/dist/lib/config/env.d.ts.map +1 -1
  17. package/dist/lib/config/env.js +2 -5
  18. package/dist/lib/config/env.js.map +1 -1
  19. package/dist/lib/feePdf.d.ts +19 -0
  20. package/dist/lib/feePdf.d.ts.map +1 -0
  21. package/dist/lib/feePdf.js +167 -0
  22. package/dist/lib/feePdf.js.map +1 -0
  23. package/dist/lib/feePdfFormat.d.ts +4 -0
  24. package/dist/lib/feePdfFormat.d.ts.map +1 -0
  25. package/dist/lib/feePdfFormat.js +16 -0
  26. package/dist/lib/feePdfFormat.js.map +1 -0
  27. package/dist/lib/idCardPdf.d.ts +26 -0
  28. package/dist/lib/idCardPdf.d.ts.map +1 -0
  29. package/dist/lib/idCardPdf.js +272 -0
  30. package/dist/lib/idCardPdf.js.map +1 -0
  31. package/dist/lib/pdfLetterhead.d.ts +41 -0
  32. package/dist/lib/pdfLetterhead.d.ts.map +1 -0
  33. package/dist/lib/pdfLetterhead.js +86 -0
  34. package/dist/lib/pdfLetterhead.js.map +1 -0
  35. package/dist/lib/transcriptPdf.d.ts +55 -0
  36. package/dist/lib/transcriptPdf.d.ts.map +1 -0
  37. package/dist/lib/transcriptPdf.js +303 -0
  38. package/dist/lib/transcriptPdf.js.map +1 -0
  39. package/dist/models/academicCalendar.d.ts +198 -0
  40. package/dist/models/academicCalendar.d.ts.map +1 -0
  41. package/dist/models/academicCalendar.js +106 -0
  42. package/dist/models/academicCalendar.js.map +1 -0
  43. package/dist/models/agenda.d.ts +8 -0
  44. package/dist/models/agenda.d.ts.map +1 -1
  45. package/dist/models/announcement.d.ts +2 -0
  46. package/dist/models/announcement.d.ts.map +1 -1
  47. package/dist/models/assignment.d.ts +66 -0
  48. package/dist/models/assignment.d.ts.map +1 -1
  49. package/dist/models/attendance.d.ts +659 -21
  50. package/dist/models/attendance.d.ts.map +1 -1
  51. package/dist/models/attendance.js +544 -75
  52. package/dist/models/attendance.js.map +1 -1
  53. package/dist/models/auth.d.ts +2 -0
  54. package/dist/models/auth.d.ts.map +1 -1
  55. package/dist/models/class.d.ts +12 -0
  56. package/dist/models/class.d.ts.map +1 -1
  57. package/dist/models/class.js +3 -2
  58. package/dist/models/class.js.map +1 -1
  59. package/dist/models/event.d.ts +16 -0
  60. package/dist/models/event.d.ts.map +1 -1
  61. package/dist/models/fees.d.ts +554 -0
  62. package/dist/models/fees.d.ts.map +1 -0
  63. package/dist/models/fees.js +278 -0
  64. package/dist/models/fees.js.map +1 -0
  65. package/dist/models/file.d.ts +29 -0
  66. package/dist/models/file.d.ts.map +1 -1
  67. package/dist/models/folder.d.ts +2 -0
  68. package/dist/models/folder.d.ts.map +1 -1
  69. package/dist/models/labChat.d.ts +2 -0
  70. package/dist/models/labChat.d.ts.map +1 -1
  71. package/dist/models/marketplace.d.ts +178 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +115 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +304 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +78 -0
  78. package/dist/models/moduleItem.js.map +1 -0
  79. package/dist/models/newtonChat.d.ts +1 -0
  80. package/dist/models/newtonChat.d.ts.map +1 -1
  81. package/dist/models/school.d.ts +18 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +7 -2
  84. package/dist/models/school.js.map +1 -1
  85. package/dist/models/section.d.ts +4 -0
  86. package/dist/models/section.d.ts.map +1 -1
  87. package/dist/models/worksheet.d.ts +19 -0
  88. package/dist/models/worksheet.d.ts.map +1 -1
  89. package/dist/models/worksheet.js +2 -2
  90. package/dist/models/worksheet.js.map +1 -1
  91. package/dist/routers/_app.d.ts +11341 -7097
  92. package/dist/routers/_app.d.ts.map +1 -1
  93. package/dist/routers/_app.js +12 -2
  94. package/dist/routers/_app.js.map +1 -1
  95. package/dist/routers/academicCalendar.d.ts +229 -0
  96. package/dist/routers/academicCalendar.d.ts.map +1 -0
  97. package/dist/routers/academicCalendar.js +75 -0
  98. package/dist/routers/academicCalendar.js.map +1 -0
  99. package/dist/routers/admin.d.ts +2 -0
  100. package/dist/routers/admin.d.ts.map +1 -1
  101. package/dist/routers/agenda.d.ts +8 -0
  102. package/dist/routers/agenda.d.ts.map +1 -1
  103. package/dist/routers/assignment.d.ts +75 -0
  104. package/dist/routers/assignment.d.ts.map +1 -1
  105. package/dist/routers/assignment.js +10 -2
  106. package/dist/routers/assignment.js.map +1 -1
  107. package/dist/routers/attendance.d.ts +103 -0
  108. package/dist/routers/attendance.d.ts.map +1 -1
  109. package/dist/routers/attendance.js +8 -5
  110. package/dist/routers/attendance.js.map +1 -1
  111. package/dist/routers/class.d.ts +27 -0
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/class.js +6 -2
  114. package/dist/routers/class.js.map +1 -1
  115. package/dist/routers/event.d.ts +8 -0
  116. package/dist/routers/event.d.ts.map +1 -1
  117. package/dist/routers/fees.d.ts +513 -0
  118. package/dist/routers/fees.d.ts.map +1 -0
  119. package/dist/routers/fees.js +88 -0
  120. package/dist/routers/fees.js.map +1 -0
  121. package/dist/routers/file.d.ts +2 -0
  122. package/dist/routers/file.d.ts.map +1 -1
  123. package/dist/routers/gradebook.d.ts +4 -1
  124. package/dist/routers/gradebook.d.ts.map +1 -1
  125. package/dist/routers/marketplace.d.ts +371 -0
  126. package/dist/routers/marketplace.d.ts.map +1 -0
  127. package/dist/routers/marketplace.js +87 -0
  128. package/dist/routers/marketplace.js.map +1 -0
  129. package/dist/routers/moduleItem.d.ts +263 -0
  130. package/dist/routers/moduleItem.d.ts.map +1 -0
  131. package/dist/routers/moduleItem.js +60 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +169 -0
  134. package/dist/routers/parent.d.ts.map +1 -0
  135. package/dist/routers/parent.js +49 -0
  136. package/dist/routers/parent.js.map +1 -0
  137. package/dist/routers/school.d.ts +363 -7
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +100 -3
  140. package/dist/routers/school.js.map +1 -1
  141. package/dist/routers/worksheet.d.ts +27 -0
  142. package/dist/routers/worksheet.d.ts.map +1 -1
  143. package/dist/routers/worksheet.js +10 -3
  144. package/dist/routers/worksheet.js.map +1 -1
  145. package/dist/services/academicCalendar.d.ts +173 -0
  146. package/dist/services/academicCalendar.d.ts.map +1 -0
  147. package/dist/services/academicCalendar.js +214 -0
  148. package/dist/services/academicCalendar.js.map +1 -0
  149. package/dist/services/adminInstitution.d.ts +2 -0
  150. package/dist/services/adminInstitution.d.ts.map +1 -1
  151. package/dist/services/agenda.d.ts +8 -0
  152. package/dist/services/agenda.d.ts.map +1 -1
  153. package/dist/services/assignment.d.ts +75 -0
  154. package/dist/services/assignment.d.ts.map +1 -1
  155. package/dist/services/assignment.js +91 -12
  156. package/dist/services/assignment.js.map +1 -1
  157. package/dist/services/attendance.d.ts +323 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +385 -25
  160. package/dist/services/attendance.js.map +1 -1
  161. package/dist/services/attendanceMerge.d.ts +53 -0
  162. package/dist/services/attendanceMerge.d.ts.map +1 -0
  163. package/dist/services/attendanceMerge.js +87 -0
  164. package/dist/services/attendanceMerge.js.map +1 -0
  165. package/dist/services/attendanceRate.d.ts +24 -0
  166. package/dist/services/attendanceRate.d.ts.map +1 -0
  167. package/dist/services/attendanceRate.js +31 -0
  168. package/dist/services/attendanceRate.js.map +1 -0
  169. package/dist/services/attendanceStats.d.ts +22 -0
  170. package/dist/services/attendanceStats.d.ts.map +1 -0
  171. package/dist/services/attendanceStats.js +45 -0
  172. package/dist/services/attendanceStats.js.map +1 -0
  173. package/dist/services/class.d.ts +27 -0
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +26 -3
  176. package/dist/services/class.js.map +1 -1
  177. package/dist/services/event.d.ts +8 -0
  178. package/dist/services/event.d.ts.map +1 -1
  179. package/dist/services/fees.d.ts +422 -0
  180. package/dist/services/fees.d.ts.map +1 -0
  181. package/dist/services/fees.js +310 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +49 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +52 -0
  186. package/dist/services/feesAuthz.js.map +1 -0
  187. package/dist/services/file.d.ts +4 -0
  188. package/dist/services/file.d.ts.map +1 -1
  189. package/dist/services/gradebook.d.ts +18 -1
  190. package/dist/services/gradebook.d.ts.map +1 -1
  191. package/dist/services/gradebook.js +57 -2
  192. package/dist/services/gradebook.js.map +1 -1
  193. package/dist/services/idCard.d.ts +11 -0
  194. package/dist/services/idCard.d.ts.map +1 -0
  195. package/dist/services/idCard.js +122 -0
  196. package/dist/services/idCard.js.map +1 -0
  197. package/dist/services/marketplace.d.ts +301 -0
  198. package/dist/services/marketplace.d.ts.map +1 -0
  199. package/dist/services/marketplace.js +628 -0
  200. package/dist/services/marketplace.js.map +1 -0
  201. package/dist/services/moduleItem.d.ts +196 -0
  202. package/dist/services/moduleItem.d.ts.map +1 -0
  203. package/dist/services/moduleItem.js +188 -0
  204. package/dist/services/moduleItem.js.map +1 -0
  205. package/dist/services/notification.d.ts +58 -0
  206. package/dist/services/notification.d.ts.map +1 -1
  207. package/dist/services/notification.js +30 -2
  208. package/dist/services/notification.js.map +1 -1
  209. package/dist/services/parentPortal.d.ts +133 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +328 -0
  212. package/dist/services/parentPortal.js.map +1 -0
  213. package/dist/services/school.d.ts +28 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +65 -45
  216. package/dist/services/school.js.map +1 -1
  217. package/dist/services/studentImport.d.ts +34 -0
  218. package/dist/services/studentImport.d.ts.map +1 -0
  219. package/dist/services/studentImport.js +236 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +61 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +243 -0
  224. package/dist/services/transcript.js.map +1 -0
  225. package/dist/services/worksheet.d.ts +22 -0
  226. package/dist/services/worksheet.d.ts.map +1 -1
  227. package/dist/services/worksheet.js +94 -2
  228. package/dist/services/worksheet.js.map +1 -1
  229. package/dist/trpc.d.ts +1 -1
  230. package/dist/trpc.d.ts.map +1 -1
  231. package/dist/trpc.js +5 -5
  232. package/dist/trpc.js.map +1 -1
  233. package/dist/utils/adminPanelAccess.d.ts +4 -5
  234. package/dist/utils/adminPanelAccess.d.ts.map +1 -1
  235. package/dist/utils/adminPanelAccess.js +12 -34
  236. package/dist/utils/adminPanelAccess.js.map +1 -1
  237. package/package.json +2 -1
  238. package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
  239. package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
  240. package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
  241. package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
  242. package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
  243. package/prisma/schema.prisma +371 -11
  244. package/scripts/import-nepal-cdc-curriculum.ts +379 -0
  245. package/src/lib/assignmentTypeDefaults.ts +222 -0
  246. package/src/lib/config/env.ts +0 -3
  247. package/src/lib/feePdf.ts +199 -0
  248. package/src/lib/feePdfFormat.ts +14 -0
  249. package/src/lib/idCardPdf.ts +336 -0
  250. package/src/lib/pdfLetterhead.ts +124 -0
  251. package/src/lib/transcriptPdf.ts +502 -0
  252. package/src/models/academicCalendar.ts +139 -0
  253. package/src/models/attendance.ts +704 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +158 -0
  257. package/src/models/moduleItem.ts +107 -0
  258. package/src/models/school.ts +6 -0
  259. package/src/models/worksheet.ts +2 -0
  260. package/src/routers/_app.ts +10 -0
  261. package/src/routers/academicCalendar.ts +103 -0
  262. package/src/routers/assignment.ts +8 -0
  263. package/src/routers/attendance.ts +7 -3
  264. package/src/routers/class.ts +4 -0
  265. package/src/routers/fees.ts +126 -0
  266. package/src/routers/marketplace.ts +134 -0
  267. package/src/routers/moduleItem.ts +89 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +139 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +299 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +548 -26
  274. package/src/services/attendanceMerge.ts +139 -0
  275. package/src/services/attendanceRate.ts +41 -0
  276. package/src/services/attendanceStats.ts +63 -0
  277. package/src/services/class.ts +25 -1
  278. package/src/services/fees.ts +424 -0
  279. package/src/services/feesAuthz.ts +77 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +785 -0
  283. package/src/services/moduleItem.ts +274 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +380 -0
  286. package/src/services/school.ts +65 -44
  287. package/src/services/studentImport.ts +273 -0
  288. package/src/services/transcript.ts +313 -0
  289. package/src/services/worksheet.ts +111 -0
  290. package/src/trpc.ts +3 -3
  291. package/src/utils/adminPanelAccess.ts +12 -35
  292. package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
  293. package/tests/unit/lib/feePdf.test.ts +15 -0
  294. package/tests/unit/lib/idCardPdf.test.ts +40 -0
  295. package/tests/unit/lib/transcriptPdf.test.ts +111 -0
  296. package/tests/unit/services/attendanceMerge.test.ts +90 -0
  297. package/tests/unit/services/attendanceRate.test.ts +38 -0
  298. package/tests/unit/services/attendanceStats.test.ts +54 -0
@@ -1,9 +1,26 @@
1
1
  /**
2
- * Attendance model – attendance records, present/late/absent students per event.
2
+ * Attendance model – session-level lists + per-student AttendanceRecord,
3
+ * and school-scoped StudentExcusal persistence.
3
4
  */
5
+ import { AttendanceStatus, Prisma } from "@prisma/client";
4
6
  import { prisma } from "../lib/prisma.js";
5
7
  import { classTeacherOrSchoolAdminWhere } from "./access.js";
6
8
 
9
+ const studentProfileSelect = {
10
+ id: true,
11
+ username: true,
12
+ profile: {
13
+ select: {
14
+ displayName: true,
15
+ profilePicture: true,
16
+ profilePictureThumbnail: true,
17
+ bio: true,
18
+ location: true,
19
+ website: true,
20
+ },
21
+ },
22
+ } as const;
23
+
7
24
  const attendanceInclude = {
8
25
  event: {
9
26
  select: {
@@ -15,50 +32,27 @@ const attendanceInclude = {
15
32
  color: true,
16
33
  },
17
34
  },
18
- present: {
35
+ present: { select: studentProfileSelect },
36
+ late: { select: studentProfileSelect },
37
+ absent: { select: studentProfileSelect },
38
+ records: {
19
39
  select: {
20
40
  id: true,
21
- username: true,
22
- profile: {
23
- select: {
24
- displayName: true,
25
- profilePicture: true,
26
- profilePictureThumbnail: true,
27
- bio: true,
28
- location: true,
29
- website: true,
30
- },
31
- },
32
- },
33
- },
34
- late: {
35
- select: {
36
- id: true,
37
- username: true,
38
- profile: {
41
+ studentId: true,
42
+ status: true,
43
+ note: true,
44
+ excusedByExcusalId: true,
45
+ updatedById: true,
46
+ updatedAt: true,
47
+ student: { select: studentProfileSelect },
48
+ excusedByExcusal: {
39
49
  select: {
40
- displayName: true,
41
- profilePicture: true,
42
- profilePictureThumbnail: true,
43
- bio: true,
44
- location: true,
45
- website: true,
46
- },
47
- },
48
- },
49
- },
50
- absent: {
51
- select: {
52
- id: true,
53
- username: true,
54
- profile: {
55
- select: {
56
- displayName: true,
57
- profilePicture: true,
58
- profilePictureThumbnail: true,
59
- bio: true,
60
- location: true,
61
- website: true,
50
+ id: true,
51
+ reason: true,
52
+ note: true,
53
+ startDate: true,
54
+ endDate: true,
55
+ revokedAt: true,
62
56
  },
63
57
  },
64
58
  },
@@ -78,15 +72,28 @@ const attendanceUpdateInclude = {
78
72
  present: { select: { id: true, username: true } },
79
73
  late: { select: { id: true, username: true } },
80
74
  absent: { select: { id: true, username: true } },
75
+ records: {
76
+ select: {
77
+ id: true,
78
+ studentId: true,
79
+ status: true,
80
+ note: true,
81
+ excusedByExcusalId: true,
82
+ updatedById: true,
83
+ updatedAt: true,
84
+ student: { select: { id: true, username: true } },
85
+ excusedByExcusal: {
86
+ select: { id: true, reason: true, note: true },
87
+ },
88
+ },
89
+ },
81
90
  } as const;
82
91
 
83
- /** @returns Class with students for attendance. */
84
- export function findClassWithStudents(classId: string) {
85
- return prisma.class.findUnique({
86
- where: { id: classId },
92
+ const excusalInclude = {
93
+ students: {
87
94
  select: {
88
- id: true,
89
- students: {
95
+ studentId: true,
96
+ student: {
90
97
  select: {
91
98
  id: true,
92
99
  username: true,
@@ -95,14 +102,39 @@ export function findClassWithStudents(classId: string) {
95
102
  displayName: true,
96
103
  profilePicture: true,
97
104
  profilePictureThumbnail: true,
98
- bio: true,
99
- location: true,
100
- website: true,
101
105
  },
102
106
  },
103
107
  },
104
108
  },
105
109
  },
110
+ },
111
+ createdBy: {
112
+ select: {
113
+ id: true,
114
+ username: true,
115
+ profile: { select: { displayName: true } },
116
+ },
117
+ },
118
+ revokedBy: {
119
+ select: {
120
+ id: true,
121
+ username: true,
122
+ profile: { select: { displayName: true } },
123
+ },
124
+ },
125
+ } as const;
126
+
127
+ /** @returns Class with students for attendance. */
128
+ export function findClassWithStudents(classId: string) {
129
+ return prisma.class.findUnique({
130
+ where: { id: classId },
131
+ select: {
132
+ id: true,
133
+ schoolId: true,
134
+ students: {
135
+ select: studentProfileSelect,
136
+ },
137
+ },
106
138
  });
107
139
  }
108
140
 
@@ -116,6 +148,7 @@ export function findClassWithTeacher(classId: string, teacherId: string) {
116
148
  select: {
117
149
  id: true,
118
150
  name: true,
151
+ schoolId: true,
119
152
  students: { select: { id: true } },
120
153
  },
121
154
  });
@@ -132,6 +165,15 @@ export function findEventsByClassId(classId: string) {
132
165
  export function findAttendanceByEventId(eventId: string) {
133
166
  return prisma.attendance.findFirst({
134
167
  where: { eventId },
168
+ include: {
169
+ records: {
170
+ select: {
171
+ studentId: true,
172
+ status: true,
173
+ excusedByExcusalId: true,
174
+ },
175
+ },
176
+ },
135
177
  });
136
178
  }
137
179
 
@@ -147,10 +189,19 @@ export function findAttendanceByClassAndEvent(classId: string, eventId: string |
147
189
  select: {
148
190
  id: true,
149
191
  name: true,
192
+ startTime: true,
150
193
  },
151
194
  },
152
195
  late: { select: { id: true } },
153
196
  absent: { select: { id: true } },
197
+ records: {
198
+ select: {
199
+ studentId: true,
200
+ status: true,
201
+ excusedByExcusalId: true,
202
+ note: true,
203
+ },
204
+ },
154
205
  },
155
206
  });
156
207
  }
@@ -167,26 +218,60 @@ export function findManyAttendance(classId: string, eventId?: string) {
167
218
  });
168
219
  }
169
220
 
170
- /** Create an attendance record. */
221
+ /** Create an attendance session with legacy lists + per-student records. */
171
222
  export function createAttendance(data: {
172
223
  classId: string;
173
224
  eventId?: string | null;
225
+ date?: Date;
174
226
  presentIds: string[];
175
227
  lateIds?: string[];
176
228
  absentIds?: string[];
229
+ excusedIds?: string[];
230
+ records?: Array<{
231
+ studentId: string;
232
+ status: AttendanceStatus;
233
+ note?: string | null;
234
+ excusedByExcusalId?: string | null;
235
+ updatedById?: string | null;
236
+ }>;
237
+ updatedById?: string | null;
177
238
  }) {
239
+ const lateIds = data.lateIds ?? [];
240
+ const absentIds = data.absentIds ?? [];
241
+ const excusedIds = data.excusedIds ?? [];
242
+ const records: Array<{
243
+ studentId: string;
244
+ status: AttendanceStatus;
245
+ note?: string | null;
246
+ excusedByExcusalId?: string | null;
247
+ updatedById?: string | null;
248
+ }> =
249
+ data.records ??
250
+ buildRecordsFromLists({
251
+ presentIds: data.presentIds,
252
+ lateIds,
253
+ absentIds,
254
+ excusedIds,
255
+ updatedById: data.updatedById,
256
+ });
257
+
178
258
  return prisma.attendance.create({
179
259
  data: {
180
260
  classId: data.classId,
181
261
  eventId: data.eventId ?? undefined,
182
- date: new Date(),
262
+ date: data.date ?? new Date(),
183
263
  present: { connect: data.presentIds.map((id) => ({ id })) },
184
- late: data.lateIds?.length
185
- ? { connect: data.lateIds.map((id) => ({ id })) }
186
- : undefined,
187
- absent: data.absentIds?.length
188
- ? { connect: data.absentIds.map((id) => ({ id })) }
189
- : undefined,
264
+ late: lateIds.length ? { connect: lateIds.map((id) => ({ id })) } : undefined,
265
+ absent: absentIds.length ? { connect: absentIds.map((id) => ({ id })) } : undefined,
266
+ records: {
267
+ create: records.map((record) => ({
268
+ studentId: record.studentId,
269
+ status: record.status,
270
+ note: record.note ?? undefined,
271
+ excusedByExcusalId: record.excusedByExcusalId ?? undefined,
272
+ updatedById: record.updatedById ?? data.updatedById ?? undefined,
273
+ })),
274
+ },
190
275
  },
191
276
  include: attendanceUpdateInclude,
192
277
  });
@@ -196,29 +281,575 @@ export function createAttendance(data: {
196
281
  export function createAttendanceForEvent(data: {
197
282
  classId: string;
198
283
  eventId: string;
284
+ date?: Date;
199
285
  presentIds: string[];
286
+ lateIds?: string[];
287
+ absentIds?: string[];
288
+ excusedIds?: string[];
289
+ records?: Array<{
290
+ studentId: string;
291
+ status: AttendanceStatus;
292
+ note?: string | null;
293
+ excusedByExcusalId?: string | null;
294
+ updatedById?: string | null;
295
+ }>;
200
296
  }) {
201
- return prisma.attendance.create({
297
+ return createAttendance({
298
+ classId: data.classId,
299
+ eventId: data.eventId,
300
+ date: data.date,
301
+ presentIds: data.presentIds,
302
+ lateIds: data.lateIds,
303
+ absentIds: data.absentIds,
304
+ excusedIds: data.excusedIds,
305
+ records: data.records,
306
+ });
307
+ }
308
+
309
+ /** Update attendance present/late/absent lists and sync per-student records. */
310
+ export async function updateAttendance(
311
+ id: string,
312
+ data: {
313
+ presentIds: string[];
314
+ lateIds: string[];
315
+ absentIds: string[];
316
+ records: Array<{
317
+ studentId: string;
318
+ status: AttendanceStatus;
319
+ note?: string | null;
320
+ excusedByExcusalId?: string | null;
321
+ updatedById?: string | null;
322
+ }>;
323
+ }
324
+ ) {
325
+ const studentIds = data.records.map((r) => r.studentId);
326
+
327
+ return prisma.$transaction(async (tx) => {
328
+ // Replace per-student rows for students in this update set.
329
+ await tx.attendanceRecord.deleteMany({
330
+ where: {
331
+ attendanceId: id,
332
+ studentId: { in: studentIds },
333
+ },
334
+ });
335
+
336
+ if (data.records.length > 0) {
337
+ await tx.attendanceRecord.createMany({
338
+ data: data.records.map((record) => ({
339
+ attendanceId: id,
340
+ studentId: record.studentId,
341
+ status: record.status,
342
+ note: record.note ?? null,
343
+ excusedByExcusalId: record.excusedByExcusalId ?? null,
344
+ updatedById: record.updatedById ?? null,
345
+ })),
346
+ });
347
+ }
348
+
349
+ return tx.attendance.update({
350
+ where: { id },
351
+ data: {
352
+ present: { set: data.presentIds.map((id) => ({ id })) },
353
+ late: { set: data.lateIds.map((id) => ({ id })) },
354
+ absent: { set: data.absentIds.map((id) => ({ id })) },
355
+ },
356
+ include: attendanceUpdateInclude,
357
+ });
358
+ });
359
+ }
360
+
361
+ function buildRecordsFromLists(input: {
362
+ presentIds: string[];
363
+ lateIds: string[];
364
+ absentIds: string[];
365
+ excusedIds: string[];
366
+ updatedById?: string | null;
367
+ }) {
368
+ const records: Array<{
369
+ studentId: string;
370
+ status: AttendanceStatus;
371
+ updatedById?: string | null;
372
+ }> = [];
373
+ for (const studentId of input.presentIds) {
374
+ records.push({ studentId, status: "PRESENT", updatedById: input.updatedById });
375
+ }
376
+ for (const studentId of input.lateIds) {
377
+ records.push({ studentId, status: "LATE", updatedById: input.updatedById });
378
+ }
379
+ for (const studentId of input.absentIds) {
380
+ records.push({ studentId, status: "ABSENT", updatedById: input.updatedById });
381
+ }
382
+ for (const studentId of input.excusedIds) {
383
+ records.push({ studentId, status: "EXCUSED", updatedById: input.updatedById });
384
+ }
385
+ return records;
386
+ }
387
+
388
+ /** Active (non-revoked) excusals covering a calendar date for given students. */
389
+ export function findActiveExcusalsForStudents(input: {
390
+ schoolId: string;
391
+ studentIds: string[];
392
+ date: Date;
393
+ }) {
394
+ if (input.studentIds.length === 0) {
395
+ return Promise.resolve([]);
396
+ }
397
+
398
+ const day = toDateOnly(input.date);
399
+
400
+ return prisma.studentExcusal.findMany({
401
+ where: {
402
+ schoolId: input.schoolId,
403
+ revokedAt: null,
404
+ startDate: { lte: day },
405
+ endDate: { gte: day },
406
+ students: { some: { studentId: { in: input.studentIds } } },
407
+ },
408
+ include: {
409
+ students: { select: { studentId: true } },
410
+ },
411
+ });
412
+ }
413
+
414
+ /** Active excusals overlapping a date range for school students. */
415
+ export function findActiveExcusalsInRange(input: {
416
+ schoolId: string;
417
+ studentIds?: string[];
418
+ startDate: Date;
419
+ endDate: Date;
420
+ }) {
421
+ const start = toDateOnly(input.startDate);
422
+ const end = toDateOnly(input.endDate);
423
+
424
+ return prisma.studentExcusal.findMany({
425
+ where: {
426
+ schoolId: input.schoolId,
427
+ revokedAt: null,
428
+ startDate: { lte: end },
429
+ endDate: { gte: start },
430
+ ...(input.studentIds?.length
431
+ ? { students: { some: { studentId: { in: input.studentIds } } } }
432
+ : {}),
433
+ },
434
+ include: {
435
+ students: { select: { studentId: true } },
436
+ },
437
+ });
438
+ }
439
+
440
+ export function listExcusalsForSchool(input: {
441
+ schoolId: string;
442
+ includeRevoked?: boolean;
443
+ studentId?: string;
444
+ }) {
445
+ return prisma.studentExcusal.findMany({
446
+ where: {
447
+ schoolId: input.schoolId,
448
+ ...(input.includeRevoked ? {} : { revokedAt: null }),
449
+ ...(input.studentId
450
+ ? { students: { some: { studentId: input.studentId } } }
451
+ : {}),
452
+ },
453
+ include: excusalInclude,
454
+ orderBy: [{ startDate: "desc" }, { createdAt: "desc" }],
455
+ });
456
+ }
457
+
458
+ export function findExcusalById(excusalId: string) {
459
+ return prisma.studentExcusal.findUnique({
460
+ where: { id: excusalId },
461
+ include: {
462
+ ...excusalInclude,
463
+ students: { select: { studentId: true } },
464
+ },
465
+ });
466
+ }
467
+
468
+ export function createStudentExcusal(data: {
469
+ schoolId: string;
470
+ studentIds: string[];
471
+ startDate: Date;
472
+ endDate: Date;
473
+ reason: string;
474
+ note?: string | null;
475
+ createdById: string;
476
+ }) {
477
+ return prisma.studentExcusal.create({
202
478
  data: {
203
- class: { connect: { id: data.classId } },
204
- event: { connect: { id: data.eventId } },
205
- present: { connect: data.presentIds.map((id) => ({ id })) },
479
+ schoolId: data.schoolId,
480
+ startDate: toDateOnly(data.startDate),
481
+ endDate: toDateOnly(data.endDate),
482
+ reason: data.reason,
483
+ note: data.note ?? null,
484
+ createdById: data.createdById,
485
+ students: {
486
+ create: data.studentIds.map((studentId) => ({ studentId })),
487
+ },
206
488
  },
489
+ include: excusalInclude,
207
490
  });
208
491
  }
209
492
 
210
- /** Update attendance present/late/absent lists. */
211
- export function updateAttendance(
212
- id: string,
213
- data: { presentIds: string[]; lateIds: string[]; absentIds: string[] }
493
+ export function updateStudentExcusal(
494
+ excusalId: string,
495
+ data: {
496
+ studentIds?: string[];
497
+ startDate?: Date;
498
+ endDate?: Date;
499
+ reason?: string;
500
+ note?: string | null;
501
+ }
214
502
  ) {
215
- return prisma.attendance.update({
216
- where: { id },
503
+ return prisma.$transaction(async (tx) => {
504
+ if (data.studentIds) {
505
+ await tx.studentExcusalStudent.deleteMany({ where: { excusalId } });
506
+ await tx.studentExcusalStudent.createMany({
507
+ data: data.studentIds.map((studentId) => ({ excusalId, studentId })),
508
+ });
509
+ }
510
+
511
+ return tx.studentExcusal.update({
512
+ where: { id: excusalId },
513
+ data: {
514
+ ...(data.startDate !== undefined
515
+ ? { startDate: toDateOnly(data.startDate) }
516
+ : {}),
517
+ ...(data.endDate !== undefined ? { endDate: toDateOnly(data.endDate) } : {}),
518
+ ...(data.reason !== undefined ? { reason: data.reason } : {}),
519
+ ...(data.note !== undefined ? { note: data.note } : {}),
520
+ },
521
+ include: excusalInclude,
522
+ });
523
+ });
524
+ }
525
+
526
+ export function revokeStudentExcusal(excusalId: string, revokedById: string) {
527
+ return prisma.studentExcusal.update({
528
+ where: { id: excusalId },
217
529
  data: {
218
- present: { set: data.presentIds.map((id) => ({ id })) },
219
- late: { set: data.lateIds.map((id) => ({ id })) },
220
- absent: { set: data.absentIds.map((id) => ({ id })) },
530
+ revokedAt: new Date(),
531
+ revokedById,
221
532
  },
222
- include: attendanceUpdateInclude,
533
+ include: excusalInclude,
534
+ });
535
+ }
536
+
537
+ /** Apply EXCUSED status for an excusal across matching class attendance sessions. */
538
+ export async function applyExcusalToAttendance(input: {
539
+ schoolId: string;
540
+ excusalId: string;
541
+ studentIds: string[];
542
+ startDate: Date;
543
+ endDate: Date;
544
+ updatedById?: string | null;
545
+ }) {
546
+ if (input.studentIds.length === 0) {
547
+ return { sessionsTouched: 0, recordsUpserted: 0 };
548
+ }
549
+
550
+ const start = startOfUtcDay(input.startDate);
551
+ const end = endOfUtcDay(input.endDate);
552
+
553
+ const events = await prisma.event.findMany({
554
+ where: {
555
+ class: {
556
+ schoolId: input.schoolId,
557
+ students: { some: { id: { in: input.studentIds } } },
558
+ },
559
+ startTime: { gte: start, lte: end },
560
+ },
561
+ select: {
562
+ id: true,
563
+ classId: true,
564
+ startTime: true,
565
+ class: {
566
+ select: {
567
+ id: true,
568
+ students: {
569
+ where: { id: { in: input.studentIds } },
570
+ select: { id: true },
571
+ },
572
+ },
573
+ },
574
+ },
575
+ });
576
+
577
+ let sessionsTouched = 0;
578
+ let recordsUpserted = 0;
579
+
580
+ for (const event of events) {
581
+ if (!event.classId || !event.class) continue;
582
+ const classStudentIds = event.class.students.map((s) => s.id);
583
+ if (classStudentIds.length === 0) continue;
584
+
585
+ let attendance = await prisma.attendance.findFirst({
586
+ where: { classId: event.classId, eventId: event.id },
587
+ include: {
588
+ present: { select: { id: true } },
589
+ late: { select: { id: true } },
590
+ absent: { select: { id: true } },
591
+ records: {
592
+ select: {
593
+ studentId: true,
594
+ status: true,
595
+ excusedByExcusalId: true,
596
+ note: true,
597
+ },
598
+ },
599
+ },
600
+ });
601
+
602
+ if (!attendance) {
603
+ // Default everyone present, then override excused students.
604
+ const allStudentIds = await prisma.class
605
+ .findUnique({
606
+ where: { id: event.classId },
607
+ select: { students: { select: { id: true } } },
608
+ })
609
+ .then((c) => c?.students.map((s) => s.id) ?? []);
610
+
611
+ const excusedSet = new Set(classStudentIds);
612
+ const presentIds = allStudentIds.filter((id) => !excusedSet.has(id));
613
+
614
+ attendance = await createAttendance({
615
+ classId: event.classId,
616
+ eventId: event.id,
617
+ date: event.startTime,
618
+ presentIds,
619
+ lateIds: [],
620
+ absentIds: [],
621
+ records: [
622
+ ...presentIds.map((studentId) => ({
623
+ studentId,
624
+ status: "PRESENT" as const,
625
+ updatedById: input.updatedById,
626
+ })),
627
+ ...classStudentIds.map((studentId) => ({
628
+ studentId,
629
+ status: "EXCUSED" as const,
630
+ note: null,
631
+ excusedByExcusalId: input.excusalId,
632
+ updatedById: input.updatedById,
633
+ })),
634
+ ],
635
+ });
636
+ sessionsTouched += 1;
637
+ recordsUpserted += classStudentIds.length;
638
+ continue;
639
+ }
640
+
641
+ const presentIds = attendance.present
642
+ .map((s) => s.id)
643
+ .filter((id) => !classStudentIds.includes(id));
644
+ const lateIds = attendance.late
645
+ .map((s) => s.id)
646
+ .filter((id) => !classStudentIds.includes(id));
647
+ const absentIds = attendance.absent
648
+ .map((s) => s.id)
649
+ .filter((id) => !classStudentIds.includes(id));
650
+
651
+ const recordByStudent = new Map(
652
+ attendance.records.map((r) => [r.studentId, r])
653
+ );
654
+ const nextRecords: Array<{
655
+ studentId: string;
656
+ status: AttendanceStatus;
657
+ note?: string | null;
658
+ excusedByExcusalId?: string | null;
659
+ updatedById?: string | null;
660
+ }> = [];
661
+
662
+ for (const [studentId, existing] of recordByStudent) {
663
+ if (classStudentIds.includes(studentId)) {
664
+ nextRecords.push({
665
+ studentId,
666
+ status: "EXCUSED",
667
+ note: existing.note,
668
+ excusedByExcusalId: input.excusalId,
669
+ updatedById: input.updatedById,
670
+ });
671
+ } else {
672
+ nextRecords.push({
673
+ studentId,
674
+ status: existing.status,
675
+ note: existing.note,
676
+ excusedByExcusalId: existing.excusedByExcusalId,
677
+ });
678
+ }
679
+ }
680
+
681
+ for (const studentId of classStudentIds) {
682
+ if (!recordByStudent.has(studentId)) {
683
+ nextRecords.push({
684
+ studentId,
685
+ status: "EXCUSED",
686
+ excusedByExcusalId: input.excusalId,
687
+ updatedById: input.updatedById,
688
+ });
689
+ }
690
+ }
691
+
692
+ await updateAttendance(attendance.id, {
693
+ presentIds,
694
+ lateIds,
695
+ absentIds,
696
+ records: nextRecords,
697
+ });
698
+ sessionsTouched += 1;
699
+ recordsUpserted += classStudentIds.length;
700
+ }
701
+
702
+ return { sessionsTouched, recordsUpserted };
703
+ }
704
+
705
+ /** Clear EXCUSED locks tied to a revoked/updated excusal (leave status EXCUSED unless cleared). */
706
+ export async function clearExcusalLocks(excusalId: string) {
707
+ return prisma.attendanceRecord.updateMany({
708
+ where: { excusedByExcusalId: excusalId },
709
+ data: { excusedByExcusalId: null },
710
+ });
711
+ }
712
+
713
+ export function listSchoolStudentMemberships(schoolId: string) {
714
+ return prisma.schoolMembership.findMany({
715
+ where: {
716
+ schoolId,
717
+ role: "STUDENT",
718
+ status: "ACTIVE",
719
+ },
720
+ select: {
721
+ userId: true,
722
+ user: {
723
+ select: {
724
+ id: true,
725
+ username: true,
726
+ profile: {
727
+ select: {
728
+ displayName: true,
729
+ profilePicture: true,
730
+ profilePictureThumbnail: true,
731
+ },
732
+ },
733
+ studentIn: {
734
+ where: { schoolId },
735
+ select: { id: true, name: true, section: true, subject: true },
736
+ },
737
+ },
738
+ },
739
+ },
740
+ orderBy: { createdAt: "asc" },
741
+ });
742
+ }
743
+
744
+ export function listAttendanceRecordsForSchoolStudents(input: {
745
+ schoolId: string;
746
+ studentIds: string[];
747
+ from?: Date;
748
+ to?: Date;
749
+ }) {
750
+ if (input.studentIds.length === 0) {
751
+ return Promise.resolve([]);
752
+ }
753
+
754
+ return prisma.attendanceRecord.findMany({
755
+ where: {
756
+ studentId: { in: input.studentIds },
757
+ attendance: {
758
+ class: { schoolId: input.schoolId },
759
+ ...(input.from || input.to
760
+ ? {
761
+ date: {
762
+ ...(input.from ? { gte: input.from } : {}),
763
+ ...(input.to ? { lte: input.to } : {}),
764
+ },
765
+ }
766
+ : {}),
767
+ },
768
+ },
769
+ select: {
770
+ studentId: true,
771
+ status: true,
772
+ attendance: {
773
+ select: {
774
+ id: true,
775
+ date: true,
776
+ classId: true,
777
+ eventId: true,
778
+ class: { select: { id: true, name: true } },
779
+ event: { select: { id: true, name: true, startTime: true } },
780
+ },
781
+ },
782
+ },
783
+ });
784
+ }
785
+
786
+ export function listTodaySchoolAttendanceSessions(schoolId: string, day: Date) {
787
+ const start = startOfUtcDay(day);
788
+ const end = endOfUtcDay(day);
789
+
790
+ return prisma.attendance.findMany({
791
+ where: {
792
+ class: { schoolId },
793
+ OR: [
794
+ { date: { gte: start, lte: end } },
795
+ { event: { startTime: { gte: start, lte: end } } },
796
+ ],
797
+ },
798
+ select: {
799
+ id: true,
800
+ date: true,
801
+ classId: true,
802
+ eventId: true,
803
+ class: {
804
+ select: {
805
+ id: true,
806
+ name: true,
807
+ section: true,
808
+ subject: true,
809
+ _count: { select: { students: true } },
810
+ },
811
+ },
812
+ event: {
813
+ select: { id: true, name: true, startTime: true, endTime: true },
814
+ },
815
+ records: {
816
+ select: {
817
+ studentId: true,
818
+ status: true,
819
+ },
820
+ },
821
+ },
822
+ orderBy: { date: "asc" },
223
823
  });
224
824
  }
825
+
826
+ export function toDateOnly(date: Date): Date {
827
+ return new Date(
828
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
829
+ );
830
+ }
831
+
832
+ export function startOfUtcDay(date: Date): Date {
833
+ return new Date(
834
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
835
+ );
836
+ }
837
+
838
+ export function endOfUtcDay(date: Date): Date {
839
+ return new Date(
840
+ Date.UTC(
841
+ date.getUTCFullYear(),
842
+ date.getUTCMonth(),
843
+ date.getUTCDate(),
844
+ 23,
845
+ 59,
846
+ 59,
847
+ 999
848
+ )
849
+ );
850
+ }
851
+
852
+ export type { AttendanceStatus };
853
+ export type AttendanceIncludeResult = Prisma.AttendanceGetPayload<{
854
+ include: typeof attendanceInclude;
855
+ }>;