@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
@@ -85,6 +85,9 @@ export async function getCurrentSchool(userId: string, schoolId?: string) {
85
85
  // signed URL through trpc.file.getSignedUrl when needed.
86
86
  logoPath: activeMembership.school.logo?.path ?? null,
87
87
  logoType: activeMembership.school.logo?.type ?? null,
88
+ letterheadFileId: activeMembership.school.letterheadFileId ?? null,
89
+ letterheadPath: activeMembership.school.letterhead?.path ?? null,
90
+ letterheadType: activeMembership.school.letterhead?.type ?? null,
88
91
  }
89
92
  : null;
90
93
 
@@ -126,6 +129,7 @@ export async function updateSchoolDetails(
126
129
  description?: string | null;
127
130
  subdomain?: string | null;
128
131
  logoId?: string | null;
132
+ letterheadFileId?: string | null;
129
133
  }
130
134
  ) {
131
135
  await requireSchoolAdmin(input.schoolId, userId);
@@ -134,6 +138,7 @@ export async function updateSchoolDetails(
134
138
  description: input.description,
135
139
  subdomain: input.subdomain,
136
140
  logoId: input.logoId,
141
+ letterheadFileId: input.letterheadFileId,
137
142
  });
138
143
  return { school: updated };
139
144
  }
@@ -153,7 +158,13 @@ export async function getAdminOverview(userId: string, schoolId: string) {
153
158
  counts[membership.role] += 1;
154
159
  return counts;
155
160
  },
156
- { ADMIN: 0, TEACHER: 0, STUDENT: 0 } satisfies Record<SchoolMembershipRole, number>
161
+ {
162
+ ADMIN: 0,
163
+ TEACHER: 0,
164
+ STUDENT: 0,
165
+ ACCOUNTANT: 0,
166
+ PARENT: 0,
167
+ } satisfies Record<SchoolMembershipRole, number>
157
168
  );
158
169
 
159
170
  return {
@@ -523,7 +534,7 @@ export async function searchMembers(
523
534
  schoolId: string,
524
535
  query: string,
525
536
  limit = 10,
526
- role?: "ADMIN" | "TEACHER" | "STUDENT"
537
+ role?: SchoolMembershipRole
527
538
  ) {
528
539
  await requireSchoolMember(schoolId, userId);
529
540
  const trimmed = query.trim();
@@ -679,36 +690,41 @@ export async function getStudentTimeline(
679
690
  },
680
691
  orderBy: { dueDate: "asc" },
681
692
  }),
682
- prisma.attendance.findMany({
693
+ prisma.attendanceRecord.findMany({
683
694
  where: {
684
- OR: [
685
- { present: { some: { id: input.studentId } } },
686
- { late: { some: { id: input.studentId } } },
687
- { absent: { some: { id: input.studentId } } },
688
- ],
689
- ...(hasRange ? { date: range } : {}),
695
+ studentId: input.studentId,
696
+ attendance: {
697
+ class: { schoolId: input.schoolId },
698
+ ...(hasRange ? { date: range } : {}),
699
+ },
690
700
  },
691
701
  select: {
692
702
  id: true,
693
- date: true,
694
- class: { select: { id: true, name: true, color: true } },
695
- present: { where: { id: input.studentId }, select: { id: true } },
696
- late: { where: { id: input.studentId }, select: { id: true } },
697
- absent: { where: { id: input.studentId }, select: { id: true } },
703
+ status: true,
704
+ note: true,
705
+ excusedByExcusalId: true,
706
+ attendance: {
707
+ select: {
708
+ id: true,
709
+ date: true,
710
+ class: { select: { id: true, name: true, color: true } },
711
+ },
712
+ },
713
+ excusedByExcusal: { select: { id: true, reason: true } },
698
714
  },
699
- orderBy: { date: "asc" },
715
+ orderBy: { attendance: { date: "asc" } },
700
716
  }),
701
717
  ]);
702
718
 
703
719
  const attendanceShaped = attendance.map((record) => ({
704
- id: record.id,
705
- date: record.date,
706
- class: record.class,
707
- status: record.present.length
708
- ? ("PRESENT" as const)
709
- : record.late.length
710
- ? ("LATE" as const)
711
- : ("ABSENT" as const),
720
+ id: record.attendance.id,
721
+ recordId: record.id,
722
+ date: record.attendance.date,
723
+ class: record.attendance.class,
724
+ status: record.status,
725
+ note: record.note,
726
+ lockedFromExcusal: Boolean(record.excusedByExcusalId),
727
+ reason: record.excusedByExcusal?.reason ?? null,
712
728
  }));
713
729
 
714
730
  return {
@@ -848,36 +864,41 @@ export async function getMemberTimeline(
848
864
  },
849
865
  orderBy: { dueDate: "asc" },
850
866
  }),
851
- prisma.attendance.findMany({
867
+ prisma.attendanceRecord.findMany({
852
868
  where: {
853
- OR: [
854
- { present: { some: { id: input.memberId } } },
855
- { late: { some: { id: input.memberId } } },
856
- { absent: { some: { id: input.memberId } } },
857
- ],
858
- ...(hasRange ? { date: range } : {}),
869
+ studentId: input.memberId,
870
+ attendance: {
871
+ class: { schoolId: input.schoolId },
872
+ ...(hasRange ? { date: range } : {}),
873
+ },
859
874
  },
860
875
  select: {
861
876
  id: true,
862
- date: true,
863
- class: { select: { id: true, name: true, color: true } },
864
- present: { where: { id: input.memberId }, select: { id: true } },
865
- late: { where: { id: input.memberId }, select: { id: true } },
866
- absent: { where: { id: input.memberId }, select: { id: true } },
877
+ status: true,
878
+ note: true,
879
+ excusedByExcusalId: true,
880
+ attendance: {
881
+ select: {
882
+ id: true,
883
+ date: true,
884
+ class: { select: { id: true, name: true, color: true } },
885
+ },
886
+ },
887
+ excusedByExcusal: { select: { id: true, reason: true } },
867
888
  },
868
- orderBy: { date: "asc" },
889
+ orderBy: { attendance: { date: "asc" } },
869
890
  }),
870
891
  ]);
871
892
 
872
893
  const attendanceShaped = attendance.map((record) => ({
873
- id: record.id,
874
- date: record.date,
875
- class: record.class,
876
- status: record.present.length
877
- ? ("PRESENT" as const)
878
- : record.late.length
879
- ? ("LATE" as const)
880
- : ("ABSENT" as const),
894
+ id: record.attendance.id,
895
+ recordId: record.id,
896
+ date: record.attendance.date,
897
+ class: record.attendance.class,
898
+ status: record.status,
899
+ note: record.note,
900
+ lockedFromExcusal: Boolean(record.excusedByExcusalId),
901
+ reason: record.excusedByExcusal?.reason ?? null,
881
902
  }));
882
903
 
883
904
  return {
@@ -0,0 +1,273 @@
1
+ /**
2
+ * C3: Bulk student CSV onboarding under school admin.
3
+ *
4
+ * CSV columns (header row required):
5
+ * displayName, username?, email?, password?, classId?
6
+ *
7
+ * Username/password are generated when omitted. Email is optional — a
8
+ * placeholder local-part is used so the User.email uniqueness constraint holds.
9
+ */
10
+ import { randomBytes } from "crypto";
11
+ import { hash } from "bcryptjs";
12
+ import { TRPCError } from "@trpc/server";
13
+ import { prisma } from "../lib/prisma.js";
14
+ import { createUser, findUserByEmail, findUserByUsername } from "../models/auth.js";
15
+ import { findActiveSchoolAdminMembership, upsertSchoolMembership } from "../models/school.js";
16
+ import { upsertUserProfile } from "../models/user.js";
17
+ import { generateUniqueUsername } from "../utils/generateUsername.js";
18
+
19
+ export type StudentCsvRowResult =
20
+ | {
21
+ ok: true;
22
+ row: number;
23
+ userId: string;
24
+ username: string;
25
+ email: string;
26
+ password: string;
27
+ displayName: string | null;
28
+ classId: string | null;
29
+ generatedUsername: boolean;
30
+ generatedPassword: boolean;
31
+ placeholderEmail: boolean;
32
+ }
33
+ | {
34
+ ok: false;
35
+ row: number;
36
+ error: string;
37
+ raw?: string;
38
+ };
39
+
40
+ function parseCsvLine(line: string): string[] {
41
+ const cells: string[] = [];
42
+ let current = "";
43
+ let inQuotes = false;
44
+ for (let i = 0; i < line.length; i += 1) {
45
+ const ch = line[i];
46
+ if (inQuotes) {
47
+ if (ch === '"') {
48
+ if (line[i + 1] === '"') {
49
+ current += '"';
50
+ i += 1;
51
+ } else {
52
+ inQuotes = false;
53
+ }
54
+ } else {
55
+ current += ch;
56
+ }
57
+ } else if (ch === '"') {
58
+ inQuotes = true;
59
+ } else if (ch === ",") {
60
+ cells.push(current.trim());
61
+ current = "";
62
+ } else {
63
+ current += ch;
64
+ }
65
+ }
66
+ cells.push(current.trim());
67
+ return cells;
68
+ }
69
+
70
+ function normalizeHeader(h: string) {
71
+ return h.trim().toLowerCase().replace(/[\s_]+/g, "");
72
+ }
73
+
74
+ function generatePassword(length = 12) {
75
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
76
+ const bytes = randomBytes(length);
77
+ let out = "";
78
+ for (let i = 0; i < length; i += 1) {
79
+ out += alphabet[bytes[i]! % alphabet.length];
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function placeholderEmail(username: string) {
85
+ const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, "");
86
+ return `${safe || "student"}.${randomBytes(3).toString("hex")}@noemail.studious.local`;
87
+ }
88
+
89
+ export function parseStudentCsv(csvText: string): {
90
+ headers: string[];
91
+ rows: Record<string, string>[];
92
+ } {
93
+ const lines = csvText
94
+ .replace(/^\uFEFF/, "")
95
+ .split(/\r?\n/)
96
+ .map((l) => l.trim())
97
+ .filter((l) => l.length > 0);
98
+ if (lines.length < 2) {
99
+ throw new TRPCError({
100
+ code: "BAD_REQUEST",
101
+ message: "CSV must include a header row and at least one data row",
102
+ });
103
+ }
104
+ const headers = parseCsvLine(lines[0]!).map(normalizeHeader);
105
+ const rows = lines.slice(1).map((line) => {
106
+ const cells = parseCsvLine(line);
107
+ const row: Record<string, string> = {};
108
+ headers.forEach((h, i) => {
109
+ row[h] = cells[i] ?? "";
110
+ });
111
+ return row;
112
+ });
113
+ return { headers, rows };
114
+ }
115
+
116
+ export async function importStudentsCsv(
117
+ userId: string,
118
+ input: { schoolId: string; csvText: string; dryRun?: boolean }
119
+ ) {
120
+ const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);
121
+ if (!membership) {
122
+ throw new TRPCError({
123
+ code: "FORBIDDEN",
124
+ message: "Only school admins can perform this action",
125
+ });
126
+ }
127
+
128
+ const { rows } = parseStudentCsv(input.csvText);
129
+ if (rows.length > 500) {
130
+ throw new TRPCError({
131
+ code: "BAD_REQUEST",
132
+ message: "CSV import is limited to 500 rows per request",
133
+ });
134
+ }
135
+
136
+ const results: StudentCsvRowResult[] = [];
137
+ let created = 0;
138
+ let failed = 0;
139
+
140
+ for (let i = 0; i < rows.length; i += 1) {
141
+ const rowNum = i + 2; // 1-indexed + header
142
+ const row = rows[i]!;
143
+ const displayName =
144
+ row.displayname || row.name || row.fullname || row.fullname || "";
145
+ const requestedUsername = row.username || "";
146
+ const requestedEmail = (row.email || "").toLowerCase();
147
+ const requestedPassword = row.password || "";
148
+ const classId = row.classid || row.class || "";
149
+
150
+ try {
151
+ if (!displayName && !requestedUsername && !requestedEmail) {
152
+ throw new Error("Row needs displayName, username, or email");
153
+ }
154
+
155
+ let username = requestedUsername;
156
+ let generatedUsername = false;
157
+ if (!username) {
158
+ username = await generateUniqueUsername(async (candidate) => {
159
+ const existing = await findUserByUsername(candidate);
160
+ return Boolean(existing);
161
+ });
162
+ generatedUsername = true;
163
+ } else {
164
+ const taken = await findUserByUsername(username);
165
+ if (taken) {
166
+ throw new Error(`Username "${username}" already taken`);
167
+ }
168
+ }
169
+
170
+ let email = requestedEmail;
171
+ let placeholderEmailUsed = false;
172
+ if (!email) {
173
+ email = placeholderEmail(username);
174
+ placeholderEmailUsed = true;
175
+ } else {
176
+ const existing = await findUserByEmail(email);
177
+ if (existing) {
178
+ throw new Error(`Email "${email}" already in use`);
179
+ }
180
+ }
181
+
182
+ let password = requestedPassword;
183
+ let generatedPassword = false;
184
+ if (!password) {
185
+ password = generatePassword();
186
+ generatedPassword = true;
187
+ }
188
+
189
+ if (classId) {
190
+ const cls = await prisma.class.findFirst({
191
+ where: { id: classId, schoolId: input.schoolId },
192
+ select: { id: true },
193
+ });
194
+ if (!cls) {
195
+ throw new Error(`classId "${classId}" not found in this school`);
196
+ }
197
+ }
198
+
199
+ if (input.dryRun) {
200
+ results.push({
201
+ ok: true,
202
+ row: rowNum,
203
+ userId: "dry-run",
204
+ username,
205
+ email,
206
+ password,
207
+ displayName: displayName || null,
208
+ classId: classId || null,
209
+ generatedUsername,
210
+ generatedPassword,
211
+ placeholderEmail: placeholderEmailUsed,
212
+ });
213
+ created += 1;
214
+ continue;
215
+ }
216
+
217
+ const hashed = await hash(password, 10);
218
+ const user = await createUser({
219
+ username,
220
+ email,
221
+ password: hashed,
222
+ verified: true,
223
+ });
224
+
225
+ if (displayName) {
226
+ await upsertUserProfile(user.id, { displayName });
227
+ }
228
+
229
+ await upsertSchoolMembership({
230
+ schoolId: input.schoolId,
231
+ userId: user.id,
232
+ role: "STUDENT",
233
+ });
234
+
235
+ if (classId) {
236
+ await prisma.class.update({
237
+ where: { id: classId },
238
+ data: { students: { connect: { id: user.id } } },
239
+ });
240
+ }
241
+
242
+ results.push({
243
+ ok: true,
244
+ row: rowNum,
245
+ userId: user.id,
246
+ username: user.username,
247
+ email: user.email,
248
+ password,
249
+ displayName: displayName || null,
250
+ classId: classId || null,
251
+ generatedUsername,
252
+ generatedPassword,
253
+ placeholderEmail: placeholderEmailUsed,
254
+ });
255
+ created += 1;
256
+ } catch (err) {
257
+ failed += 1;
258
+ results.push({
259
+ ok: false,
260
+ row: rowNum,
261
+ error: err instanceof Error ? err.message : "Unknown error",
262
+ });
263
+ }
264
+ }
265
+
266
+ return {
267
+ dryRun: Boolean(input.dryRun),
268
+ created,
269
+ failed,
270
+ total: rows.length,
271
+ results,
272
+ };
273
+ }