@studious-lms/server 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/.env.example +0 -1
  2. package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
  3. package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
  4. package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
  5. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
  6. package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
  7. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
  8. package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
  9. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
  10. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
  11. package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
  12. package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
  13. package/dist/lib/assignmentTypeDefaults.js +167 -0
  14. package/dist/lib/assignmentTypeDefaults.js.map +1 -0
  15. package/dist/lib/config/env.d.ts +0 -8
  16. package/dist/lib/config/env.d.ts.map +1 -1
  17. package/dist/lib/config/env.js +2 -5
  18. package/dist/lib/config/env.js.map +1 -1
  19. package/dist/lib/feePdf.d.ts +19 -0
  20. package/dist/lib/feePdf.d.ts.map +1 -0
  21. package/dist/lib/feePdf.js +167 -0
  22. package/dist/lib/feePdf.js.map +1 -0
  23. package/dist/lib/feePdfFormat.d.ts +4 -0
  24. package/dist/lib/feePdfFormat.d.ts.map +1 -0
  25. package/dist/lib/feePdfFormat.js +16 -0
  26. package/dist/lib/feePdfFormat.js.map +1 -0
  27. package/dist/lib/idCardPdf.d.ts +26 -0
  28. package/dist/lib/idCardPdf.d.ts.map +1 -0
  29. package/dist/lib/idCardPdf.js +272 -0
  30. package/dist/lib/idCardPdf.js.map +1 -0
  31. package/dist/lib/pdfLetterhead.d.ts +41 -0
  32. package/dist/lib/pdfLetterhead.d.ts.map +1 -0
  33. package/dist/lib/pdfLetterhead.js +86 -0
  34. package/dist/lib/pdfLetterhead.js.map +1 -0
  35. package/dist/lib/transcriptPdf.d.ts +55 -0
  36. package/dist/lib/transcriptPdf.d.ts.map +1 -0
  37. package/dist/lib/transcriptPdf.js +303 -0
  38. package/dist/lib/transcriptPdf.js.map +1 -0
  39. package/dist/models/academicCalendar.d.ts +198 -0
  40. package/dist/models/academicCalendar.d.ts.map +1 -0
  41. package/dist/models/academicCalendar.js +106 -0
  42. package/dist/models/academicCalendar.js.map +1 -0
  43. package/dist/models/agenda.d.ts +8 -0
  44. package/dist/models/agenda.d.ts.map +1 -1
  45. package/dist/models/announcement.d.ts +2 -0
  46. package/dist/models/announcement.d.ts.map +1 -1
  47. package/dist/models/assignment.d.ts +66 -0
  48. package/dist/models/assignment.d.ts.map +1 -1
  49. package/dist/models/attendance.d.ts +659 -21
  50. package/dist/models/attendance.d.ts.map +1 -1
  51. package/dist/models/attendance.js +547 -75
  52. package/dist/models/attendance.js.map +1 -1
  53. package/dist/models/auth.d.ts +2 -0
  54. package/dist/models/auth.d.ts.map +1 -1
  55. package/dist/models/class.d.ts +12 -0
  56. package/dist/models/class.d.ts.map +1 -1
  57. package/dist/models/class.js +3 -2
  58. package/dist/models/class.js.map +1 -1
  59. package/dist/models/event.d.ts +16 -0
  60. package/dist/models/event.d.ts.map +1 -1
  61. package/dist/models/fees.d.ts +554 -0
  62. package/dist/models/fees.d.ts.map +1 -0
  63. package/dist/models/fees.js +278 -0
  64. package/dist/models/fees.js.map +1 -0
  65. package/dist/models/file.d.ts +29 -0
  66. package/dist/models/file.d.ts.map +1 -1
  67. package/dist/models/folder.d.ts +2 -0
  68. package/dist/models/folder.d.ts.map +1 -1
  69. package/dist/models/labChat.d.ts +2 -0
  70. package/dist/models/labChat.d.ts.map +1 -1
  71. package/dist/models/marketplace.d.ts +180 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +116 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +308 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +93 -0
  78. package/dist/models/moduleItem.js.map +1 -0
  79. package/dist/models/newtonChat.d.ts +1 -0
  80. package/dist/models/newtonChat.d.ts.map +1 -1
  81. package/dist/models/school.d.ts +18 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +7 -2
  84. package/dist/models/school.js.map +1 -1
  85. package/dist/models/section.d.ts +4 -0
  86. package/dist/models/section.d.ts.map +1 -1
  87. package/dist/models/worksheet.d.ts +19 -0
  88. package/dist/models/worksheet.d.ts.map +1 -1
  89. package/dist/models/worksheet.js +2 -2
  90. package/dist/models/worksheet.js.map +1 -1
  91. package/dist/routers/_app.d.ts +12024 -7778
  92. package/dist/routers/_app.d.ts.map +1 -1
  93. package/dist/routers/_app.js +12 -2
  94. package/dist/routers/_app.js.map +1 -1
  95. package/dist/routers/academicCalendar.d.ts +229 -0
  96. package/dist/routers/academicCalendar.d.ts.map +1 -0
  97. package/dist/routers/academicCalendar.js +75 -0
  98. package/dist/routers/academicCalendar.js.map +1 -0
  99. package/dist/routers/admin.d.ts +2 -0
  100. package/dist/routers/admin.d.ts.map +1 -1
  101. package/dist/routers/agenda.d.ts +8 -0
  102. package/dist/routers/agenda.d.ts.map +1 -1
  103. package/dist/routers/assignment.d.ts +75 -0
  104. package/dist/routers/assignment.d.ts.map +1 -1
  105. package/dist/routers/assignment.js +10 -2
  106. package/dist/routers/assignment.js.map +1 -1
  107. package/dist/routers/attendance.d.ts +103 -0
  108. package/dist/routers/attendance.d.ts.map +1 -1
  109. package/dist/routers/attendance.js +8 -5
  110. package/dist/routers/attendance.js.map +1 -1
  111. package/dist/routers/class.d.ts +27 -0
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/class.js +6 -2
  114. package/dist/routers/class.js.map +1 -1
  115. package/dist/routers/event.d.ts +8 -0
  116. package/dist/routers/event.d.ts.map +1 -1
  117. package/dist/routers/fees.d.ts +513 -0
  118. package/dist/routers/fees.d.ts.map +1 -0
  119. package/dist/routers/fees.js +88 -0
  120. package/dist/routers/fees.js.map +1 -0
  121. package/dist/routers/file.d.ts +2 -0
  122. package/dist/routers/file.d.ts.map +1 -1
  123. package/dist/routers/gradebook.d.ts +4 -1
  124. package/dist/routers/gradebook.d.ts.map +1 -1
  125. package/dist/routers/marketplace.d.ts +371 -0
  126. package/dist/routers/marketplace.d.ts.map +1 -0
  127. package/dist/routers/marketplace.js +87 -0
  128. package/dist/routers/marketplace.js.map +1 -0
  129. package/dist/routers/moduleItem.d.ts +263 -0
  130. package/dist/routers/moduleItem.d.ts.map +1 -0
  131. package/dist/routers/moduleItem.js +61 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +170 -0
  134. package/dist/routers/parent.d.ts.map +1 -0
  135. package/dist/routers/parent.js +49 -0
  136. package/dist/routers/parent.js.map +1 -0
  137. package/dist/routers/school.d.ts +363 -7
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +110 -3
  140. package/dist/routers/school.js.map +1 -1
  141. package/dist/routers/worksheet.d.ts +27 -0
  142. package/dist/routers/worksheet.d.ts.map +1 -1
  143. package/dist/routers/worksheet.js +10 -3
  144. package/dist/routers/worksheet.js.map +1 -1
  145. package/dist/services/academicCalendar.d.ts +173 -0
  146. package/dist/services/academicCalendar.d.ts.map +1 -0
  147. package/dist/services/academicCalendar.js +242 -0
  148. package/dist/services/academicCalendar.js.map +1 -0
  149. package/dist/services/adminInstitution.d.ts +2 -0
  150. package/dist/services/adminInstitution.d.ts.map +1 -1
  151. package/dist/services/agenda.d.ts +8 -0
  152. package/dist/services/agenda.d.ts.map +1 -1
  153. package/dist/services/assignment.d.ts +75 -0
  154. package/dist/services/assignment.d.ts.map +1 -1
  155. package/dist/services/assignment.js +91 -12
  156. package/dist/services/assignment.js.map +1 -1
  157. package/dist/services/attendance.d.ts +327 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +389 -25
  160. package/dist/services/attendance.js.map +1 -1
  161. package/dist/services/attendanceMerge.d.ts +53 -0
  162. package/dist/services/attendanceMerge.d.ts.map +1 -0
  163. package/dist/services/attendanceMerge.js +87 -0
  164. package/dist/services/attendanceMerge.js.map +1 -0
  165. package/dist/services/attendanceRate.d.ts +24 -0
  166. package/dist/services/attendanceRate.d.ts.map +1 -0
  167. package/dist/services/attendanceRate.js +31 -0
  168. package/dist/services/attendanceRate.js.map +1 -0
  169. package/dist/services/attendanceStats.d.ts +22 -0
  170. package/dist/services/attendanceStats.d.ts.map +1 -0
  171. package/dist/services/attendanceStats.js +45 -0
  172. package/dist/services/attendanceStats.js.map +1 -0
  173. package/dist/services/class.d.ts +34 -2
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +27 -4
  176. package/dist/services/class.js.map +1 -1
  177. package/dist/services/event.d.ts +8 -0
  178. package/dist/services/event.d.ts.map +1 -1
  179. package/dist/services/fees.d.ts +422 -0
  180. package/dist/services/fees.d.ts.map +1 -0
  181. package/dist/services/fees.js +330 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +46 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +49 -0
  186. package/dist/services/feesAuthz.js.map +1 -0
  187. package/dist/services/file.d.ts +4 -0
  188. package/dist/services/file.d.ts.map +1 -1
  189. package/dist/services/gradebook.d.ts +18 -1
  190. package/dist/services/gradebook.d.ts.map +1 -1
  191. package/dist/services/gradebook.js +57 -2
  192. package/dist/services/gradebook.js.map +1 -1
  193. package/dist/services/idCard.d.ts +11 -0
  194. package/dist/services/idCard.d.ts.map +1 -0
  195. package/dist/services/idCard.js +122 -0
  196. package/dist/services/idCard.js.map +1 -0
  197. package/dist/services/marketplace.d.ts +301 -0
  198. package/dist/services/marketplace.d.ts.map +1 -0
  199. package/dist/services/marketplace.js +631 -0
  200. package/dist/services/marketplace.js.map +1 -0
  201. package/dist/services/moduleItem.d.ts +196 -0
  202. package/dist/services/moduleItem.d.ts.map +1 -0
  203. package/dist/services/moduleItem.js +204 -0
  204. package/dist/services/moduleItem.js.map +1 -0
  205. package/dist/services/notification.d.ts +58 -0
  206. package/dist/services/notification.d.ts.map +1 -1
  207. package/dist/services/notification.js +30 -2
  208. package/dist/services/notification.js.map +1 -1
  209. package/dist/services/parentPortal.d.ts +131 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +314 -0
  212. package/dist/services/parentPortal.js.map +1 -0
  213. package/dist/services/school.d.ts +28 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +98 -54
  216. package/dist/services/school.js.map +1 -1
  217. package/dist/services/studentImport.d.ts +34 -0
  218. package/dist/services/studentImport.d.ts.map +1 -0
  219. package/dist/services/studentImport.js +249 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +60 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +231 -0
  224. package/dist/services/transcript.js.map +1 -0
  225. package/dist/services/worksheet.d.ts +22 -0
  226. package/dist/services/worksheet.d.ts.map +1 -1
  227. package/dist/services/worksheet.js +94 -2
  228. package/dist/services/worksheet.js.map +1 -1
  229. package/dist/trpc.d.ts +1 -1
  230. package/dist/trpc.d.ts.map +1 -1
  231. package/dist/trpc.js +5 -5
  232. package/dist/trpc.js.map +1 -1
  233. package/dist/utils/adminPanelAccess.d.ts +4 -5
  234. package/dist/utils/adminPanelAccess.d.ts.map +1 -1
  235. package/dist/utils/adminPanelAccess.js +12 -34
  236. package/dist/utils/adminPanelAccess.js.map +1 -1
  237. package/package.json +2 -1
  238. package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
  239. package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
  240. package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
  241. package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
  242. package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
  243. package/prisma/schema.prisma +371 -11
  244. package/scripts/import-nepal-cdc-curriculum.ts +402 -0
  245. package/src/lib/assignmentTypeDefaults.ts +222 -0
  246. package/src/lib/config/env.ts +0 -3
  247. package/src/lib/feePdf.ts +199 -0
  248. package/src/lib/feePdfFormat.ts +14 -0
  249. package/src/lib/idCardPdf.ts +336 -0
  250. package/src/lib/pdfLetterhead.ts +124 -0
  251. package/src/lib/transcriptPdf.ts +502 -0
  252. package/src/models/academicCalendar.ts +139 -0
  253. package/src/models/attendance.ts +702 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +161 -0
  257. package/src/models/moduleItem.ts +125 -0
  258. package/src/models/school.ts +6 -0
  259. package/src/models/worksheet.ts +2 -0
  260. package/src/routers/_app.ts +10 -0
  261. package/src/routers/academicCalendar.ts +103 -0
  262. package/src/routers/assignment.ts +8 -0
  263. package/src/routers/attendance.ts +7 -3
  264. package/src/routers/class.ts +4 -0
  265. package/src/routers/fees.ts +126 -0
  266. package/src/routers/marketplace.ts +134 -0
  267. package/src/routers/moduleItem.ts +91 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +149 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +327 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +552 -26
  274. package/src/services/attendanceMerge.ts +139 -0
  275. package/src/services/attendanceRate.ts +41 -0
  276. package/src/services/attendanceStats.ts +63 -0
  277. package/src/services/class.ts +26 -2
  278. package/src/services/fees.ts +447 -0
  279. package/src/services/feesAuthz.ts +73 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +790 -0
  283. package/src/services/moduleItem.ts +298 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +367 -0
  286. package/src/services/school.ts +100 -54
  287. package/src/services/studentImport.ts +285 -0
  288. package/src/services/transcript.ts +301 -0
  289. package/src/services/worksheet.ts +111 -0
  290. package/src/trpc.ts +3 -3
  291. package/src/utils/adminPanelAccess.ts +12 -35
  292. package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
  293. package/tests/unit/lib/feePdf.test.ts +15 -0
  294. package/tests/unit/lib/idCardPdf.test.ts +40 -0
  295. package/tests/unit/lib/transcriptPdf.test.ts +111 -0
  296. package/tests/unit/services/attendanceMerge.test.ts +90 -0
  297. package/tests/unit/services/attendanceRate.test.ts +38 -0
  298. package/tests/unit/services/attendanceStats.test.ts +54 -0
@@ -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,14 +129,38 @@ 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);
136
+ const brandingFileIds = [input.logoId, input.letterheadFileId].filter(
137
+ (id): id is string => typeof id === "string"
138
+ );
139
+ if (brandingFileIds.length) {
140
+ const accessible = await prisma.file.findMany({
141
+ where: {
142
+ id: { in: brandingFileIds },
143
+ OR: [
144
+ { userId },
145
+ { folder: { schoolStorageId: input.schoolId } },
146
+ ],
147
+ },
148
+ select: { id: true },
149
+ });
150
+ const accessibleIds = new Set(accessible.map((f) => f.id));
151
+ if (brandingFileIds.some((id) => !accessibleIds.has(id))) {
152
+ throw new TRPCError({
153
+ code: "FORBIDDEN",
154
+ message: "File does not belong to this school",
155
+ });
156
+ }
157
+ }
132
158
  const updated = await updateSchoolRecord(input.schoolId, {
133
159
  name: input.name,
134
160
  description: input.description,
135
161
  subdomain: input.subdomain,
136
162
  logoId: input.logoId,
163
+ letterheadFileId: input.letterheadFileId,
137
164
  });
138
165
  return { school: updated };
139
166
  }
@@ -153,7 +180,13 @@ export async function getAdminOverview(userId: string, schoolId: string) {
153
180
  counts[membership.role] += 1;
154
181
  return counts;
155
182
  },
156
- { ADMIN: 0, TEACHER: 0, STUDENT: 0 } satisfies Record<SchoolMembershipRole, number>
183
+ {
184
+ ADMIN: 0,
185
+ TEACHER: 0,
186
+ STUDENT: 0,
187
+ ACCOUNTANT: 0,
188
+ PARENT: 0,
189
+ } satisfies Record<SchoolMembershipRole, number>
157
190
  );
158
191
 
159
192
  return {
@@ -523,7 +556,7 @@ export async function searchMembers(
523
556
  schoolId: string,
524
557
  query: string,
525
558
  limit = 10,
526
- role?: "ADMIN" | "TEACHER" | "STUDENT"
559
+ role?: SchoolMembershipRole
527
560
  ) {
528
561
  await requireSchoolMember(schoolId, userId);
529
562
  const trimmed = query.trim();
@@ -617,17 +650,18 @@ export async function getStudentTimeline(
617
650
  });
618
651
  }
619
652
 
653
+ // Default to the last 12 months when no range is given, to bound reads.
654
+ const defaultFrom = new Date();
655
+ defaultFrom.setFullYear(defaultFrom.getFullYear() - 1);
620
656
  const range = {
621
- ...(input.from ? { gte: input.from } : {}),
657
+ gte: input.from ?? defaultFrom,
622
658
  ...(input.to ? { lte: input.to } : {}),
623
659
  };
624
- const hasRange = Object.keys(range).length > 0;
625
-
626
660
  const [events, assignments, attendance] = await Promise.all([
627
661
  prisma.event.findMany({
628
662
  where: {
629
663
  userId: input.studentId,
630
- ...(hasRange ? { startTime: range } : {}),
664
+ startTime: range,
631
665
  },
632
666
  select: {
633
667
  id: true,
@@ -647,7 +681,7 @@ export async function getStudentTimeline(
647
681
  students: { some: { id: input.studentId } },
648
682
  },
649
683
  template: false,
650
- ...(hasRange ? { dueDate: range } : {}),
684
+ dueDate: range,
651
685
  },
652
686
  select: {
653
687
  id: true,
@@ -679,36 +713,41 @@ export async function getStudentTimeline(
679
713
  },
680
714
  orderBy: { dueDate: "asc" },
681
715
  }),
682
- prisma.attendance.findMany({
716
+ prisma.attendanceRecord.findMany({
683
717
  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 } : {}),
718
+ studentId: input.studentId,
719
+ attendance: {
720
+ class: { schoolId: input.schoolId },
721
+ date: range,
722
+ },
690
723
  },
691
724
  select: {
692
725
  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 } },
726
+ status: true,
727
+ note: true,
728
+ excusedByExcusalId: true,
729
+ attendance: {
730
+ select: {
731
+ id: true,
732
+ date: true,
733
+ class: { select: { id: true, name: true, color: true } },
734
+ },
735
+ },
736
+ excusedByExcusal: { select: { id: true, reason: true } },
698
737
  },
699
- orderBy: { date: "asc" },
738
+ orderBy: { attendance: { date: "asc" } },
700
739
  }),
701
740
  ]);
702
741
 
703
742
  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),
743
+ id: record.attendance.id,
744
+ recordId: record.id,
745
+ date: record.attendance.date,
746
+ class: record.attendance.class,
747
+ status: record.status,
748
+ note: record.note,
749
+ lockedFromExcusal: Boolean(record.excusedByExcusalId),
750
+ reason: record.excusedByExcusal?.reason ?? null,
712
751
  }));
713
752
 
714
753
  return {
@@ -770,11 +809,13 @@ export async function getMemberTimeline(
770
809
  });
771
810
  }
772
811
 
812
+ // Default to the last 12 months when no range is given, to bound reads.
813
+ const defaultFrom = new Date();
814
+ defaultFrom.setFullYear(defaultFrom.getFullYear() - 1);
773
815
  const range = {
774
- ...(input.from ? { gte: input.from } : {}),
816
+ gte: input.from ?? defaultFrom,
775
817
  ...(input.to ? { lte: input.to } : {}),
776
818
  };
777
- const hasRange = Object.keys(range).length > 0;
778
819
 
779
820
  if (membership.role === "STUDENT") {
780
821
  if (!callerIsAdmin) {
@@ -798,7 +839,7 @@ export async function getMemberTimeline(
798
839
  prisma.event.findMany({
799
840
  where: {
800
841
  userId: input.memberId,
801
- ...(hasRange ? { startTime: range } : {}),
842
+ startTime: range,
802
843
  },
803
844
  select: {
804
845
  id: true,
@@ -816,7 +857,7 @@ export async function getMemberTimeline(
816
857
  where: {
817
858
  class: { students: { some: { id: input.memberId } } },
818
859
  template: false,
819
- ...(hasRange ? { dueDate: range } : {}),
860
+ dueDate: range,
820
861
  },
821
862
  select: {
822
863
  id: true,
@@ -848,36 +889,41 @@ export async function getMemberTimeline(
848
889
  },
849
890
  orderBy: { dueDate: "asc" },
850
891
  }),
851
- prisma.attendance.findMany({
892
+ prisma.attendanceRecord.findMany({
852
893
  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 } : {}),
894
+ studentId: input.memberId,
895
+ attendance: {
896
+ class: { schoolId: input.schoolId },
897
+ date: range,
898
+ },
859
899
  },
860
900
  select: {
861
901
  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 } },
902
+ status: true,
903
+ note: true,
904
+ excusedByExcusalId: true,
905
+ attendance: {
906
+ select: {
907
+ id: true,
908
+ date: true,
909
+ class: { select: { id: true, name: true, color: true } },
910
+ },
911
+ },
912
+ excusedByExcusal: { select: { id: true, reason: true } },
867
913
  },
868
- orderBy: { date: "asc" },
914
+ orderBy: { attendance: { date: "asc" } },
869
915
  }),
870
916
  ]);
871
917
 
872
918
  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),
919
+ id: record.attendance.id,
920
+ recordId: record.id,
921
+ date: record.attendance.date,
922
+ class: record.attendance.class,
923
+ status: record.status,
924
+ note: record.note,
925
+ lockedFromExcusal: Boolean(record.excusedByExcusalId),
926
+ reason: record.excusedByExcusal?.reason ?? null,
881
927
  }));
882
928
 
883
929
  return {
@@ -904,7 +950,7 @@ export async function getMemberTimeline(
904
950
  { userId: input.memberId },
905
951
  { class: { teachers: { some: { id: input.memberId } } } },
906
952
  ],
907
- ...(hasRange ? { startTime: range } : {}),
953
+ startTime: range,
908
954
  },
909
955
  select: {
910
956
  id: true,
@@ -0,0 +1,285 @@
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 { findUserByEmail, findUserByUsername } from "../models/auth.js";
15
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
16
+ import { generateUniqueUsername } from "../utils/generateUsername.js";
17
+
18
+ export type StudentCsvRowResult =
19
+ | {
20
+ ok: true;
21
+ row: number;
22
+ userId: string;
23
+ username: string;
24
+ email: string;
25
+ password: string;
26
+ displayName: string | null;
27
+ classId: string | null;
28
+ generatedUsername: boolean;
29
+ generatedPassword: boolean;
30
+ placeholderEmail: boolean;
31
+ }
32
+ | {
33
+ ok: false;
34
+ row: number;
35
+ error: string;
36
+ raw?: string;
37
+ };
38
+
39
+ function parseCsvLine(line: string): string[] {
40
+ const cells: string[] = [];
41
+ let current = "";
42
+ let inQuotes = false;
43
+ for (let i = 0; i < line.length; i += 1) {
44
+ const ch = line[i];
45
+ if (inQuotes) {
46
+ if (ch === '"') {
47
+ if (line[i + 1] === '"') {
48
+ current += '"';
49
+ i += 1;
50
+ } else {
51
+ inQuotes = false;
52
+ }
53
+ } else {
54
+ current += ch;
55
+ }
56
+ } else if (ch === '"') {
57
+ inQuotes = true;
58
+ } else if (ch === ",") {
59
+ cells.push(current.trim());
60
+ current = "";
61
+ } else {
62
+ current += ch;
63
+ }
64
+ }
65
+ cells.push(current.trim());
66
+ return cells;
67
+ }
68
+
69
+ function normalizeHeader(h: string) {
70
+ return h.trim().toLowerCase().replace(/[\s_]+/g, "");
71
+ }
72
+
73
+ function generatePassword(length = 12) {
74
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
75
+ const bytes = randomBytes(length);
76
+ let out = "";
77
+ for (let i = 0; i < length; i += 1) {
78
+ out += alphabet[bytes[i]! % alphabet.length];
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function placeholderEmail(username: string) {
84
+ const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, "");
85
+ return `${safe || "student"}.${randomBytes(3).toString("hex")}@noemail.studious.local`;
86
+ }
87
+
88
+ export function parseStudentCsv(csvText: string): {
89
+ headers: string[];
90
+ rows: Record<string, string>[];
91
+ } {
92
+ const lines = csvText
93
+ .replace(/^\uFEFF/, "")
94
+ .split(/\r?\n/)
95
+ .map((l) => l.trim())
96
+ .filter((l) => l.length > 0);
97
+ if (lines.length < 2) {
98
+ throw new TRPCError({
99
+ code: "BAD_REQUEST",
100
+ message: "CSV must include a header row and at least one data row",
101
+ });
102
+ }
103
+ const headers = parseCsvLine(lines[0]!).map(normalizeHeader);
104
+ const rows = lines.slice(1).map((line) => {
105
+ const cells = parseCsvLine(line);
106
+ const row: Record<string, string> = {};
107
+ headers.forEach((h, i) => {
108
+ row[h] = cells[i] ?? "";
109
+ });
110
+ return row;
111
+ });
112
+ return { headers, rows };
113
+ }
114
+
115
+ export async function importStudentsCsv(
116
+ userId: string,
117
+ input: { schoolId: string; csvText: string; dryRun?: boolean }
118
+ ) {
119
+ const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);
120
+ if (!membership) {
121
+ throw new TRPCError({
122
+ code: "FORBIDDEN",
123
+ message: "Only school admins can perform this action",
124
+ });
125
+ }
126
+
127
+ const { rows } = parseStudentCsv(input.csvText);
128
+ if (rows.length > 500) {
129
+ throw new TRPCError({
130
+ code: "BAD_REQUEST",
131
+ message: "CSV import is limited to 500 rows per request",
132
+ });
133
+ }
134
+
135
+ const results: StudentCsvRowResult[] = [];
136
+ let created = 0;
137
+ let failed = 0;
138
+
139
+ for (let i = 0; i < rows.length; i += 1) {
140
+ const rowNum = i + 2; // 1-indexed + header
141
+ const row = rows[i]!;
142
+ const displayName = row.displayname || row.name || row.fullname || "";
143
+ const requestedUsername = row.username || "";
144
+ const requestedEmail = (row.email || "").toLowerCase();
145
+ const requestedPassword = row.password || "";
146
+ const classId = row.classid || row.class || "";
147
+
148
+ try {
149
+ if (!displayName && !requestedUsername && !requestedEmail) {
150
+ throw new Error("Row needs displayName, username, or email");
151
+ }
152
+
153
+ let username = requestedUsername;
154
+ let generatedUsername = false;
155
+ if (!username) {
156
+ username = await generateUniqueUsername(async (candidate) => {
157
+ const existing = await findUserByUsername(candidate);
158
+ return Boolean(existing);
159
+ });
160
+ generatedUsername = true;
161
+ } else {
162
+ const taken = await findUserByUsername(username);
163
+ if (taken) {
164
+ throw new Error(`Username "${username}" already taken`);
165
+ }
166
+ }
167
+
168
+ let email = requestedEmail;
169
+ let placeholderEmailUsed = false;
170
+ if (!email) {
171
+ email = placeholderEmail(username);
172
+ placeholderEmailUsed = true;
173
+ } else {
174
+ const existing = await findUserByEmail(email);
175
+ if (existing) {
176
+ throw new Error(`Email "${email}" already in use`);
177
+ }
178
+ }
179
+
180
+ let password = requestedPassword;
181
+ let generatedPassword = false;
182
+ if (!password) {
183
+ password = generatePassword();
184
+ generatedPassword = true;
185
+ }
186
+
187
+ if (classId) {
188
+ const cls = await prisma.class.findFirst({
189
+ where: { id: classId, schoolId: input.schoolId },
190
+ select: { id: true },
191
+ });
192
+ if (!cls) {
193
+ throw new Error(`classId "${classId}" not found in this school`);
194
+ }
195
+ }
196
+
197
+ if (input.dryRun) {
198
+ results.push({
199
+ ok: true,
200
+ row: rowNum,
201
+ userId: "dry-run",
202
+ username,
203
+ email,
204
+ password,
205
+ displayName: displayName || null,
206
+ classId: classId || null,
207
+ generatedUsername,
208
+ generatedPassword,
209
+ placeholderEmail: placeholderEmailUsed,
210
+ });
211
+ created += 1;
212
+ continue;
213
+ }
214
+
215
+ const hashed = await hash(password, 10);
216
+ const user = await prisma.$transaction(async (tx) => {
217
+ const createdUser = await tx.user.create({
218
+ data: {
219
+ username,
220
+ email,
221
+ password: hashed,
222
+ verified: true,
223
+ profile: { create: displayName ? { displayName } : {} },
224
+ },
225
+ select: { id: true, username: true, email: true },
226
+ });
227
+
228
+ await tx.schoolMembership.upsert({
229
+ where: {
230
+ schoolId_userId: {
231
+ schoolId: input.schoolId,
232
+ userId: createdUser.id,
233
+ },
234
+ },
235
+ update: { role: "STUDENT", status: "ACTIVE" },
236
+ create: {
237
+ schoolId: input.schoolId,
238
+ userId: createdUser.id,
239
+ role: "STUDENT",
240
+ status: "ACTIVE",
241
+ },
242
+ });
243
+
244
+ if (classId) {
245
+ await tx.class.update({
246
+ where: { id: classId },
247
+ data: { students: { connect: { id: createdUser.id } } },
248
+ });
249
+ }
250
+
251
+ return createdUser;
252
+ });
253
+
254
+ results.push({
255
+ ok: true,
256
+ row: rowNum,
257
+ userId: user.id,
258
+ username: user.username,
259
+ email: user.email,
260
+ password,
261
+ displayName: displayName || null,
262
+ classId: classId || null,
263
+ generatedUsername,
264
+ generatedPassword,
265
+ placeholderEmail: placeholderEmailUsed,
266
+ });
267
+ created += 1;
268
+ } catch (err) {
269
+ failed += 1;
270
+ results.push({
271
+ ok: false,
272
+ row: rowNum,
273
+ error: err instanceof Error ? err.message : "Unknown error",
274
+ });
275
+ }
276
+ }
277
+
278
+ return {
279
+ dryRun: Boolean(input.dryRun),
280
+ created,
281
+ failed,
282
+ total: rows.length,
283
+ results,
284
+ };
285
+ }