@studious-lms/server 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/.env.example +0 -1
  2. package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
  3. package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
  4. package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
  5. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
  6. package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
  7. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
  8. package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
  9. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
  10. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
  11. package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
  12. package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
  13. package/dist/lib/assignmentTypeDefaults.js +167 -0
  14. package/dist/lib/assignmentTypeDefaults.js.map +1 -0
  15. package/dist/lib/config/env.d.ts +0 -8
  16. package/dist/lib/config/env.d.ts.map +1 -1
  17. package/dist/lib/config/env.js +2 -5
  18. package/dist/lib/config/env.js.map +1 -1
  19. package/dist/lib/feePdf.d.ts +19 -0
  20. package/dist/lib/feePdf.d.ts.map +1 -0
  21. package/dist/lib/feePdf.js +167 -0
  22. package/dist/lib/feePdf.js.map +1 -0
  23. package/dist/lib/feePdfFormat.d.ts +4 -0
  24. package/dist/lib/feePdfFormat.d.ts.map +1 -0
  25. package/dist/lib/feePdfFormat.js +16 -0
  26. package/dist/lib/feePdfFormat.js.map +1 -0
  27. package/dist/lib/idCardPdf.d.ts +26 -0
  28. package/dist/lib/idCardPdf.d.ts.map +1 -0
  29. package/dist/lib/idCardPdf.js +272 -0
  30. package/dist/lib/idCardPdf.js.map +1 -0
  31. package/dist/lib/pdfLetterhead.d.ts +41 -0
  32. package/dist/lib/pdfLetterhead.d.ts.map +1 -0
  33. package/dist/lib/pdfLetterhead.js +86 -0
  34. package/dist/lib/pdfLetterhead.js.map +1 -0
  35. package/dist/lib/transcriptPdf.d.ts +55 -0
  36. package/dist/lib/transcriptPdf.d.ts.map +1 -0
  37. package/dist/lib/transcriptPdf.js +303 -0
  38. package/dist/lib/transcriptPdf.js.map +1 -0
  39. package/dist/models/academicCalendar.d.ts +198 -0
  40. package/dist/models/academicCalendar.d.ts.map +1 -0
  41. package/dist/models/academicCalendar.js +106 -0
  42. package/dist/models/academicCalendar.js.map +1 -0
  43. package/dist/models/agenda.d.ts +8 -0
  44. package/dist/models/agenda.d.ts.map +1 -1
  45. package/dist/models/announcement.d.ts +2 -0
  46. package/dist/models/announcement.d.ts.map +1 -1
  47. package/dist/models/assignment.d.ts +66 -0
  48. package/dist/models/assignment.d.ts.map +1 -1
  49. package/dist/models/attendance.d.ts +659 -21
  50. package/dist/models/attendance.d.ts.map +1 -1
  51. package/dist/models/attendance.js +544 -75
  52. package/dist/models/attendance.js.map +1 -1
  53. package/dist/models/auth.d.ts +2 -0
  54. package/dist/models/auth.d.ts.map +1 -1
  55. package/dist/models/class.d.ts +12 -0
  56. package/dist/models/class.d.ts.map +1 -1
  57. package/dist/models/class.js +3 -2
  58. package/dist/models/class.js.map +1 -1
  59. package/dist/models/event.d.ts +16 -0
  60. package/dist/models/event.d.ts.map +1 -1
  61. package/dist/models/fees.d.ts +554 -0
  62. package/dist/models/fees.d.ts.map +1 -0
  63. package/dist/models/fees.js +278 -0
  64. package/dist/models/fees.js.map +1 -0
  65. package/dist/models/file.d.ts +29 -0
  66. package/dist/models/file.d.ts.map +1 -1
  67. package/dist/models/folder.d.ts +2 -0
  68. package/dist/models/folder.d.ts.map +1 -1
  69. package/dist/models/labChat.d.ts +2 -0
  70. package/dist/models/labChat.d.ts.map +1 -1
  71. package/dist/models/marketplace.d.ts +178 -0
  72. package/dist/models/marketplace.d.ts.map +1 -0
  73. package/dist/models/marketplace.js +115 -0
  74. package/dist/models/marketplace.js.map +1 -0
  75. package/dist/models/moduleItem.d.ts +304 -0
  76. package/dist/models/moduleItem.d.ts.map +1 -0
  77. package/dist/models/moduleItem.js +78 -0
  78. package/dist/models/moduleItem.js.map +1 -0
  79. package/dist/models/newtonChat.d.ts +1 -0
  80. package/dist/models/newtonChat.d.ts.map +1 -1
  81. package/dist/models/school.d.ts +193 -0
  82. package/dist/models/school.d.ts.map +1 -1
  83. package/dist/models/school.js +80 -2
  84. package/dist/models/school.js.map +1 -1
  85. package/dist/models/section.d.ts +4 -0
  86. package/dist/models/section.d.ts.map +1 -1
  87. package/dist/models/worksheet.d.ts +19 -0
  88. package/dist/models/worksheet.d.ts.map +1 -1
  89. package/dist/models/worksheet.js +2 -2
  90. package/dist/models/worksheet.js.map +1 -1
  91. package/dist/routers/_app.d.ts +11605 -7133
  92. package/dist/routers/_app.d.ts.map +1 -1
  93. package/dist/routers/_app.js +12 -2
  94. package/dist/routers/_app.js.map +1 -1
  95. package/dist/routers/academicCalendar.d.ts +229 -0
  96. package/dist/routers/academicCalendar.d.ts.map +1 -0
  97. package/dist/routers/academicCalendar.js +75 -0
  98. package/dist/routers/academicCalendar.js.map +1 -0
  99. package/dist/routers/admin.d.ts +2 -0
  100. package/dist/routers/admin.d.ts.map +1 -1
  101. package/dist/routers/agenda.d.ts +8 -0
  102. package/dist/routers/agenda.d.ts.map +1 -1
  103. package/dist/routers/assignment.d.ts +75 -0
  104. package/dist/routers/assignment.d.ts.map +1 -1
  105. package/dist/routers/assignment.js +10 -2
  106. package/dist/routers/assignment.js.map +1 -1
  107. package/dist/routers/attendance.d.ts +103 -0
  108. package/dist/routers/attendance.d.ts.map +1 -1
  109. package/dist/routers/attendance.js +8 -5
  110. package/dist/routers/attendance.js.map +1 -1
  111. package/dist/routers/class.d.ts +27 -0
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/class.js +6 -2
  114. package/dist/routers/class.js.map +1 -1
  115. package/dist/routers/event.d.ts +8 -0
  116. package/dist/routers/event.d.ts.map +1 -1
  117. package/dist/routers/fees.d.ts +513 -0
  118. package/dist/routers/fees.d.ts.map +1 -0
  119. package/dist/routers/fees.js +88 -0
  120. package/dist/routers/fees.js.map +1 -0
  121. package/dist/routers/file.d.ts +2 -0
  122. package/dist/routers/file.d.ts.map +1 -1
  123. package/dist/routers/gradebook.d.ts +4 -1
  124. package/dist/routers/gradebook.d.ts.map +1 -1
  125. package/dist/routers/marketplace.d.ts +371 -0
  126. package/dist/routers/marketplace.d.ts.map +1 -0
  127. package/dist/routers/marketplace.js +87 -0
  128. package/dist/routers/marketplace.js.map +1 -0
  129. package/dist/routers/moduleItem.d.ts +263 -0
  130. package/dist/routers/moduleItem.d.ts.map +1 -0
  131. package/dist/routers/moduleItem.js +60 -0
  132. package/dist/routers/moduleItem.js.map +1 -0
  133. package/dist/routers/parent.d.ts +169 -0
  134. package/dist/routers/parent.d.ts.map +1 -0
  135. package/dist/routers/parent.js +49 -0
  136. package/dist/routers/parent.js.map +1 -0
  137. package/dist/routers/school.d.ts +476 -6
  138. package/dist/routers/school.d.ts.map +1 -1
  139. package/dist/routers/school.js +130 -4
  140. package/dist/routers/school.js.map +1 -1
  141. package/dist/routers/worksheet.d.ts +27 -0
  142. package/dist/routers/worksheet.d.ts.map +1 -1
  143. package/dist/routers/worksheet.js +10 -3
  144. package/dist/routers/worksheet.js.map +1 -1
  145. package/dist/services/academicCalendar.d.ts +173 -0
  146. package/dist/services/academicCalendar.d.ts.map +1 -0
  147. package/dist/services/academicCalendar.js +214 -0
  148. package/dist/services/academicCalendar.js.map +1 -0
  149. package/dist/services/adminInstitution.d.ts +2 -0
  150. package/dist/services/adminInstitution.d.ts.map +1 -1
  151. package/dist/services/agenda.d.ts +8 -0
  152. package/dist/services/agenda.d.ts.map +1 -1
  153. package/dist/services/assignment.d.ts +75 -0
  154. package/dist/services/assignment.d.ts.map +1 -1
  155. package/dist/services/assignment.js +91 -12
  156. package/dist/services/assignment.js.map +1 -1
  157. package/dist/services/attendance.d.ts +323 -10
  158. package/dist/services/attendance.d.ts.map +1 -1
  159. package/dist/services/attendance.js +385 -25
  160. package/dist/services/attendance.js.map +1 -1
  161. package/dist/services/attendanceMerge.d.ts +53 -0
  162. package/dist/services/attendanceMerge.d.ts.map +1 -0
  163. package/dist/services/attendanceMerge.js +87 -0
  164. package/dist/services/attendanceMerge.js.map +1 -0
  165. package/dist/services/attendanceRate.d.ts +24 -0
  166. package/dist/services/attendanceRate.d.ts.map +1 -0
  167. package/dist/services/attendanceRate.js +31 -0
  168. package/dist/services/attendanceRate.js.map +1 -0
  169. package/dist/services/attendanceStats.d.ts +22 -0
  170. package/dist/services/attendanceStats.d.ts.map +1 -0
  171. package/dist/services/attendanceStats.js +45 -0
  172. package/dist/services/attendanceStats.js.map +1 -0
  173. package/dist/services/class.d.ts +27 -0
  174. package/dist/services/class.d.ts.map +1 -1
  175. package/dist/services/class.js +26 -3
  176. package/dist/services/class.js.map +1 -1
  177. package/dist/services/event.d.ts +8 -0
  178. package/dist/services/event.d.ts.map +1 -1
  179. package/dist/services/fees.d.ts +422 -0
  180. package/dist/services/fees.d.ts.map +1 -0
  181. package/dist/services/fees.js +310 -0
  182. package/dist/services/fees.js.map +1 -0
  183. package/dist/services/feesAuthz.d.ts +49 -0
  184. package/dist/services/feesAuthz.d.ts.map +1 -0
  185. package/dist/services/feesAuthz.js +52 -0
  186. package/dist/services/feesAuthz.js.map +1 -0
  187. package/dist/services/file.d.ts +4 -0
  188. package/dist/services/file.d.ts.map +1 -1
  189. package/dist/services/gradebook.d.ts +18 -1
  190. package/dist/services/gradebook.d.ts.map +1 -1
  191. package/dist/services/gradebook.js +57 -2
  192. package/dist/services/gradebook.js.map +1 -1
  193. package/dist/services/idCard.d.ts +11 -0
  194. package/dist/services/idCard.d.ts.map +1 -0
  195. package/dist/services/idCard.js +122 -0
  196. package/dist/services/idCard.js.map +1 -0
  197. package/dist/services/marketplace.d.ts +301 -0
  198. package/dist/services/marketplace.d.ts.map +1 -0
  199. package/dist/services/marketplace.js +628 -0
  200. package/dist/services/marketplace.js.map +1 -0
  201. package/dist/services/moduleItem.d.ts +196 -0
  202. package/dist/services/moduleItem.d.ts.map +1 -0
  203. package/dist/services/moduleItem.js +188 -0
  204. package/dist/services/moduleItem.js.map +1 -0
  205. package/dist/services/notification.d.ts +58 -0
  206. package/dist/services/notification.d.ts.map +1 -1
  207. package/dist/services/notification.js +30 -2
  208. package/dist/services/notification.js.map +1 -1
  209. package/dist/services/parentPortal.d.ts +133 -0
  210. package/dist/services/parentPortal.d.ts.map +1 -0
  211. package/dist/services/parentPortal.js +328 -0
  212. package/dist/services/parentPortal.js.map +1 -0
  213. package/dist/services/school.d.ts +122 -3
  214. package/dist/services/school.d.ts.map +1 -1
  215. package/dist/services/school.js +179 -47
  216. package/dist/services/school.js.map +1 -1
  217. package/dist/services/studentImport.d.ts +34 -0
  218. package/dist/services/studentImport.d.ts.map +1 -0
  219. package/dist/services/studentImport.js +236 -0
  220. package/dist/services/studentImport.js.map +1 -0
  221. package/dist/services/transcript.d.ts +61 -0
  222. package/dist/services/transcript.d.ts.map +1 -0
  223. package/dist/services/transcript.js +243 -0
  224. package/dist/services/transcript.js.map +1 -0
  225. package/dist/services/worksheet.d.ts +22 -0
  226. package/dist/services/worksheet.d.ts.map +1 -1
  227. package/dist/services/worksheet.js +94 -2
  228. package/dist/services/worksheet.js.map +1 -1
  229. package/dist/trpc.d.ts +1 -1
  230. package/dist/trpc.d.ts.map +1 -1
  231. package/dist/trpc.js +5 -5
  232. package/dist/trpc.js.map +1 -1
  233. package/dist/utils/adminPanelAccess.d.ts +4 -5
  234. package/dist/utils/adminPanelAccess.d.ts.map +1 -1
  235. package/dist/utils/adminPanelAccess.js +12 -34
  236. package/dist/utils/adminPanelAccess.js.map +1 -1
  237. package/package.json +2 -1
  238. package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
  239. package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
  240. package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
  241. package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
  242. package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
  243. package/prisma/schema.prisma +371 -11
  244. package/scripts/import-nepal-cdc-curriculum.ts +379 -0
  245. package/src/lib/assignmentTypeDefaults.ts +222 -0
  246. package/src/lib/config/env.ts +0 -3
  247. package/src/lib/feePdf.ts +199 -0
  248. package/src/lib/feePdfFormat.ts +14 -0
  249. package/src/lib/idCardPdf.ts +336 -0
  250. package/src/lib/pdfLetterhead.ts +124 -0
  251. package/src/lib/transcriptPdf.ts +502 -0
  252. package/src/models/academicCalendar.ts +139 -0
  253. package/src/models/attendance.ts +704 -73
  254. package/src/models/class.ts +1 -0
  255. package/src/models/fees.ts +349 -0
  256. package/src/models/marketplace.ts +158 -0
  257. package/src/models/moduleItem.ts +107 -0
  258. package/src/models/school.ts +95 -0
  259. package/src/models/worksheet.ts +2 -0
  260. package/src/routers/_app.ts +10 -0
  261. package/src/routers/academicCalendar.ts +103 -0
  262. package/src/routers/assignment.ts +8 -0
  263. package/src/routers/attendance.ts +7 -3
  264. package/src/routers/class.ts +4 -0
  265. package/src/routers/fees.ts +126 -0
  266. package/src/routers/marketplace.ts +134 -0
  267. package/src/routers/moduleItem.ts +89 -0
  268. package/src/routers/parent.ts +68 -0
  269. package/src/routers/school.ts +184 -1
  270. package/src/routers/worksheet.ts +11 -0
  271. package/src/services/academicCalendar.ts +299 -0
  272. package/src/services/assignment.ts +122 -12
  273. package/src/services/attendance.ts +548 -26
  274. package/src/services/attendanceMerge.ts +139 -0
  275. package/src/services/attendanceRate.ts +41 -0
  276. package/src/services/attendanceStats.ts +63 -0
  277. package/src/services/class.ts +25 -1
  278. package/src/services/fees.ts +424 -0
  279. package/src/services/feesAuthz.ts +77 -0
  280. package/src/services/gradebook.ts +70 -0
  281. package/src/services/idCard.ts +147 -0
  282. package/src/services/marketplace.ts +785 -0
  283. package/src/services/moduleItem.ts +274 -0
  284. package/src/services/notification.ts +43 -0
  285. package/src/services/parentPortal.ts +380 -0
  286. package/src/services/school.ts +243 -45
  287. package/src/services/studentImport.ts +273 -0
  288. package/src/services/transcript.ts +313 -0
  289. package/src/services/worksheet.ts +111 -0
  290. package/src/trpc.ts +3 -3
  291. package/src/utils/adminPanelAccess.ts +12 -35
  292. package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
  293. package/tests/unit/lib/feePdf.test.ts +15 -0
  294. package/tests/unit/lib/idCardPdf.test.ts +40 -0
  295. package/tests/unit/lib/transcriptPdf.test.ts +111 -0
  296. package/tests/unit/services/attendanceMerge.test.ts +90 -0
  297. package/tests/unit/services/attendanceRate.test.ts +38 -0
  298. package/tests/unit/services/attendanceStats.test.ts +54 -0
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
+ import {
4
+ getChildAttendance,
5
+ getChildGrades,
6
+ linkParentToStudent,
7
+ listMyChildren,
8
+ listParentLinks,
9
+ unlinkParentFromStudent,
10
+ } from "../services/parentPortal.js";
11
+
12
+ export const parentRouter = createTRPCRouter({
13
+ linkStudent: protectedProcedure
14
+ .input(
15
+ z.object({
16
+ schoolId: z.string(),
17
+ parentUserId: z.string(),
18
+ studentUserId: z.string(),
19
+ })
20
+ )
21
+ .mutation(({ ctx, input }) => linkParentToStudent(ctx.user!.id, input)),
22
+
23
+ unlinkStudent: protectedProcedure
24
+ .input(
25
+ z.object({
26
+ schoolId: z.string(),
27
+ parentUserId: z.string(),
28
+ studentUserId: z.string(),
29
+ })
30
+ )
31
+ .mutation(({ ctx, input }) => unlinkParentFromStudent(ctx.user!.id, input)),
32
+
33
+ listLinks: protectedProcedure
34
+ .input(
35
+ z.object({
36
+ schoolId: z.string(),
37
+ parentUserId: z.string().optional(),
38
+ studentUserId: z.string().optional(),
39
+ })
40
+ )
41
+ .query(({ ctx, input }) => listParentLinks(ctx.user!.id, input)),
42
+
43
+ listMyChildren: protectedProcedure
44
+ .input(z.object({ schoolId: z.string() }))
45
+ .query(({ ctx, input }) => listMyChildren(ctx.user!.id, input.schoolId)),
46
+
47
+ getChildAttendance: protectedProcedure
48
+ .input(
49
+ z.object({
50
+ schoolId: z.string(),
51
+ studentUserId: z.string(),
52
+ classId: z.string().optional(),
53
+ from: z.date().optional(),
54
+ to: z.date().optional(),
55
+ })
56
+ )
57
+ .query(({ ctx, input }) => getChildAttendance(ctx.user!.id, input)),
58
+
59
+ getChildGrades: protectedProcedure
60
+ .input(
61
+ z.object({
62
+ schoolId: z.string(),
63
+ studentUserId: z.string(),
64
+ classId: z.string().optional(),
65
+ })
66
+ )
67
+ .query(({ ctx, input }) => getChildGrades(ctx.user!.id, input)),
68
+ });
@@ -7,13 +7,17 @@ import {
7
7
  import {
8
8
  acceptInvite,
9
9
  addMember,
10
+ bulkRemoveMembers,
11
+ bulkUpdateMemberRoles,
10
12
  confirmSchoolAssetUpload,
11
13
  createInvite,
12
14
  createSchoolClass,
13
15
  createSchoolRecord,
16
+ deleteSchoolClass,
14
17
  getAdminOverview,
15
18
  getCurrentSchool,
16
19
  getInvite,
20
+ getMemberDetail,
17
21
  getMemberTimeline,
18
22
  getSchoolAssetUploadUrl,
19
23
  getStudentTimeline,
@@ -24,17 +28,47 @@ import {
24
28
  removeMember,
25
29
  revokeInvite,
26
30
  searchMembers,
31
+ updateClassMembers,
27
32
  updateMemberRole,
28
33
  updateSchoolDetails,
29
34
  } from "../services/school.js";
30
35
  import { getSchoolAiUsage } from "../services/aiUsage.js";
36
+ import {
37
+ listTodayAttendanceSummary,
38
+ listStudentAttendanceStats,
39
+ listExcusals,
40
+ createExcusal,
41
+ updateExcusal,
42
+ revokeExcusal,
43
+ } from "../services/attendance.js";
44
+ import { generateStudentIdCards } from "../services/idCard.js";
45
+ import {
46
+ generateTranscript,
47
+ generateTranscripts,
48
+ } from "../services/transcript.js";
49
+ import { importStudentsCsv } from "../services/studentImport.js";
31
50
 
32
- const schoolRoleSchema = z.enum(["ADMIN", "TEACHER", "STUDENT"]);
51
+ const schoolRoleSchema = z.enum([
52
+ "ADMIN",
53
+ "TEACHER",
54
+ "STUDENT",
55
+ "ACCOUNTANT",
56
+ "PARENT",
57
+ ]);
33
58
 
34
59
  const schoolIdSchema = z.object({
35
60
  schoolId: z.string(),
36
61
  });
37
62
 
63
+ /** Interim period params until AcademicTerm (C1) exists. */
64
+ const transcriptPeriodSchema = z.object({
65
+ schoolId: z.string(),
66
+ from: z.date().optional(),
67
+ to: z.date().optional(),
68
+ /** Free-text school year label (e.g. "2082 BS"). Prefer AcademicTerm (C1) later. */
69
+ schoolYear: z.string().max(64).optional(),
70
+ });
71
+
38
72
  export const schoolRouter = createTRPCRouter({
39
73
  getCurrent: protectedProcedure
40
74
  .input(z.object({ schoolId: z.string().optional() }).optional())
@@ -61,6 +95,7 @@ export const schoolRouter = createTRPCRouter({
61
95
  description: z.string().nullable().optional(),
62
96
  subdomain: z.string().nullable().optional(),
63
97
  logoId: z.string().nullable().optional(),
98
+ letterheadFileId: z.string().nullable().optional(),
64
99
  })
65
100
  )
66
101
  .mutation(({ ctx, input }) => updateSchoolDetails(ctx.user!.id, input)),
@@ -143,6 +178,66 @@ export const schoolRouter = createTRPCRouter({
143
178
  .input(schoolIdSchema)
144
179
  .query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
145
180
 
181
+ /** School-admin: today's attendance rollup across classes. */
182
+ listTodayAttendanceSummary: protectedProcedure
183
+ .input(schoolIdSchema)
184
+ .query(({ ctx, input }) =>
185
+ listTodayAttendanceSummary(ctx.user!.id, input.schoolId)
186
+ ),
187
+
188
+ /** School-admin: per-student attendance counts + % (excused excluded from %). */
189
+ listStudentAttendanceStats: protectedProcedure
190
+ .input(
191
+ z.object({
192
+ schoolId: z.string(),
193
+ from: z.date().optional(),
194
+ to: z.date().optional(),
195
+ studentId: z.string().optional(),
196
+ })
197
+ )
198
+ .query(({ ctx, input }) => listStudentAttendanceStats(ctx.user!.id, input)),
199
+
200
+ listExcusals: protectedProcedure
201
+ .input(
202
+ z.object({
203
+ schoolId: z.string(),
204
+ includeRevoked: z.boolean().optional(),
205
+ studentId: z.string().optional(),
206
+ })
207
+ )
208
+ .query(({ ctx, input }) => listExcusals(ctx.user!.id, input)),
209
+
210
+ createExcusal: protectedProcedure
211
+ .input(
212
+ z.object({
213
+ schoolId: z.string(),
214
+ studentIds: z.array(z.string()).min(1),
215
+ startDate: z.date(),
216
+ endDate: z.date(),
217
+ reason: z.string().min(1),
218
+ note: z.string().nullable().optional(),
219
+ })
220
+ )
221
+ .mutation(({ ctx, input }) => createExcusal(ctx.user!.id, input)),
222
+
223
+ updateExcusal: protectedProcedure
224
+ .input(
225
+ z.object({
226
+ schoolId: z.string(),
227
+ excusalId: z.string(),
228
+ studentIds: z.array(z.string()).min(1).optional(),
229
+ startDate: z.date().optional(),
230
+ endDate: z.date().optional(),
231
+ reason: z.string().min(1).optional(),
232
+ note: z.string().nullable().optional(),
233
+ })
234
+ )
235
+ .mutation(({ ctx, input }) => updateExcusal(ctx.user!.id, input)),
236
+
237
+ revokeExcusal: protectedProcedure
238
+ .input(z.object({ schoolId: z.string(), excusalId: z.string() }))
239
+ .mutation(({ ctx, input }) => revokeExcusal(ctx.user!.id, input)),
240
+
146
241
  getAiUsage: protectedProcedure
147
242
  .input(
148
243
  z.object({
@@ -174,6 +269,23 @@ export const schoolRouter = createTRPCRouter({
174
269
  )
175
270
  .mutation(({ ctx, input }) => createSchoolClass(ctx.user!.id, input)),
176
271
 
272
+ deleteClass: protectedProcedure
273
+ .input(z.object({ schoolId: z.string(), classId: z.string() }))
274
+ .mutation(({ ctx, input }) => deleteSchoolClass(ctx.user!.id, input)),
275
+
276
+ updateClassMembers: protectedProcedure
277
+ .input(
278
+ z.object({
279
+ schoolId: z.string(),
280
+ classId: z.string(),
281
+ addTeacherIds: z.array(z.string()).optional(),
282
+ removeTeacherIds: z.array(z.string()).optional(),
283
+ addStudentIds: z.array(z.string()).optional(),
284
+ removeStudentIds: z.array(z.string()).optional(),
285
+ })
286
+ )
287
+ .mutation(({ ctx, input }) => updateClassMembers(ctx.user!.id, input)),
288
+
177
289
  listMembers: protectedProcedure
178
290
  .input(schoolIdSchema)
179
291
  .query(({ ctx, input }) => listMembers(ctx.user!.id, input.schoolId)),
@@ -202,6 +314,66 @@ export const schoolRouter = createTRPCRouter({
202
314
  .input(z.object({ schoolId: z.string(), memberUserId: z.string() }))
203
315
  .mutation(({ ctx, input }) => removeMember(ctx.user!.id, input)),
204
316
 
317
+ getMemberDetail: protectedProcedure
318
+ .input(z.object({ schoolId: z.string(), memberUserId: z.string() }))
319
+ .query(({ ctx, input }) => getMemberDetail(ctx.user!.id, input)),
320
+
321
+ /**
322
+ * Generate a branded transcript / progress-report PDF for one student.
323
+ * School admin only. Uses gradebook + attendance (present/late/absent).
324
+ * No AcademicTerm yet — pass schoolYear and/or from/to (C1 dependency).
325
+ */
326
+ generateTranscript: protectedProcedure
327
+ .input(
328
+ transcriptPeriodSchema.extend({
329
+ studentId: z.string(),
330
+ })
331
+ )
332
+ .mutation(({ ctx, input }) => generateTranscript(ctx.user!.id, input)),
333
+
334
+ /**
335
+ * Bulk generate transcripts for multiple students (max 50).
336
+ * Returns per-student PDFs plus any per-student errors.
337
+ */
338
+ generateTranscripts: protectedProcedure
339
+ .input(
340
+ transcriptPeriodSchema.extend({
341
+ studentIds: z.array(z.string()).min(1).max(50),
342
+ })
343
+ )
344
+ .mutation(({ ctx, input }) => generateTranscripts(ctx.user!.id, input)),
345
+
346
+ bulkUpdateMemberRoles: protectedProcedure
347
+ .input(
348
+ z.object({
349
+ schoolId: z.string(),
350
+ memberUserIds: z.array(z.string()).min(1).max(200),
351
+ role: schoolRoleSchema,
352
+ })
353
+ )
354
+ .mutation(({ ctx, input }) => bulkUpdateMemberRoles(ctx.user!.id, input)),
355
+
356
+ bulkRemoveMembers: protectedProcedure
357
+ .input(
358
+ z.object({
359
+ schoolId: z.string(),
360
+ memberUserIds: z.array(z.string()).min(1).max(200),
361
+ })
362
+ )
363
+ .mutation(({ ctx, input }) => bulkRemoveMembers(ctx.user!.id, input)),
364
+
365
+ /** Generate a bulk PDF of front-side student ID cards for selected members. */
366
+ generateIdCards: protectedProcedure
367
+ .input(
368
+ z.object({
369
+ schoolId: z.string(),
370
+ studentUserIds: z.array(z.string()).min(1).max(100),
371
+ })
372
+ )
373
+ .mutation(({ ctx, input }) =>
374
+ generateStudentIdCards(ctx.user!.id, input)
375
+ ),
376
+
205
377
  listInvites: protectedProcedure
206
378
  .input(schoolIdSchema)
207
379
  .query(({ ctx, input }) => listInvites(ctx.user!.id, input.schoolId)),
@@ -232,4 +404,15 @@ export const schoolRouter = createTRPCRouter({
232
404
  acceptInvite: protectedProcedure
233
405
  .input(z.object({ token: z.string() }))
234
406
  .mutation(({ ctx, input }) => acceptInvite(ctx.user!.id, input.token)),
407
+
408
+ /** C3: Bulk student CSV onboarding (username/password generated when omitted). */
409
+ importStudentsCsv: protectedProcedure
410
+ .input(
411
+ z.object({
412
+ schoolId: z.string(),
413
+ csvText: z.string().min(1).max(2_000_000),
414
+ dryRun: z.boolean().optional(),
415
+ })
416
+ )
417
+ .mutation(({ ctx, input }) => importStudentsCsv(ctx.user!.id, input)),
235
418
  });
@@ -33,6 +33,7 @@ import {
33
33
  regradeAll,
34
34
  gradeAnswer,
35
35
  addCommentToResponse,
36
+ getWorksheetQuestionStats,
36
37
  } from "../services/worksheet.js";
37
38
 
38
39
  const directFileSchema = z.object({
@@ -235,6 +236,7 @@ export const worksheetRouter = createTRPCRouter({
235
236
  "ESSAY",
236
237
  ]),
237
238
  order: z.number().int().min(0).optional(),
239
+ parentQuestionId: z.string().nullable().optional(),
238
240
  })
239
241
  )
240
242
  .mutation(({ ctx, input }) =>
@@ -246,9 +248,16 @@ export const worksheetRouter = createTRPCRouter({
246
248
  markScheme: input.markScheme,
247
249
  type: input.type,
248
250
  order: input.order,
251
+ parentQuestionId: input.parentQuestionId,
249
252
  })
250
253
  ),
251
254
 
255
+ getQuestionStats: protectedProcedure
256
+ .input(z.object({ worksheetId: z.string() }))
257
+ .query(({ ctx, input }) =>
258
+ getWorksheetQuestionStats(input.worksheetId, ctx.user!.id)
259
+ ),
260
+
252
261
  reorderQuestions: protectedProcedure
253
262
  .input(
254
263
  z.object({
@@ -288,6 +297,7 @@ export const worksheetRouter = createTRPCRouter({
288
297
  "ESSAY",
289
298
  ])
290
299
  .optional(),
300
+ parentQuestionId: z.string().nullable().optional(),
291
301
  })
292
302
  )
293
303
  .mutation(({ ctx, input }) =>
@@ -302,6 +312,7 @@ export const worksheetRouter = createTRPCRouter({
302
312
  options: input.options,
303
313
  markScheme: input.markScheme,
304
314
  type: input.type,
315
+ parentQuestionId: input.parentQuestionId,
305
316
  }
306
317
  )
307
318
  ),
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Academic year / term service (C1) + student promotion (C2).
3
+ */
4
+ import { TRPCError } from "@trpc/server";
5
+ import { prisma } from "../lib/prisma.js";
6
+ import {
7
+ createAcademicTerm,
8
+ createAcademicYear,
9
+ deleteAcademicTerm,
10
+ deleteAcademicYear,
11
+ getAcademicYearById,
12
+ getCurrentAcademicYear,
13
+ listAcademicYears,
14
+ updateAcademicTerm,
15
+ updateAcademicYear,
16
+ } from "../models/academicCalendar.js";
17
+ import {
18
+ findActiveSchoolAdminMembership,
19
+ findSchoolClass,
20
+ listActiveSchoolMemberships,
21
+ } from "../models/school.js";
22
+
23
+ async function requireSchoolAdmin(schoolId: string, userId: string) {
24
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
25
+ if (!membership) {
26
+ throw new TRPCError({
27
+ code: "FORBIDDEN",
28
+ message: "Only school admins can perform this action",
29
+ });
30
+ }
31
+ return membership;
32
+ }
33
+
34
+ export async function listYears(userId: string, schoolId: string) {
35
+ await requireSchoolAdmin(schoolId, userId);
36
+ return { years: await listAcademicYears(schoolId) };
37
+ }
38
+
39
+ export async function getCurrentYear(userId: string, schoolId: string) {
40
+ await requireSchoolAdmin(schoolId, userId);
41
+ return { year: await getCurrentAcademicYear(schoolId) };
42
+ }
43
+
44
+ export async function createYear(
45
+ userId: string,
46
+ input: {
47
+ schoolId: string;
48
+ name: string;
49
+ startDate: Date;
50
+ endDate: Date;
51
+ isCurrent?: boolean;
52
+ }
53
+ ) {
54
+ await requireSchoolAdmin(input.schoolId, userId);
55
+ if (input.endDate < input.startDate) {
56
+ throw new TRPCError({
57
+ code: "BAD_REQUEST",
58
+ message: "endDate must be on or after startDate",
59
+ });
60
+ }
61
+ try {
62
+ const year = await createAcademicYear(input);
63
+ return { year };
64
+ } catch (err: unknown) {
65
+ if (
66
+ err &&
67
+ typeof err === "object" &&
68
+ "code" in err &&
69
+ (err as { code: string }).code === "P2002"
70
+ ) {
71
+ throw new TRPCError({
72
+ code: "CONFLICT",
73
+ message: "An academic year with this name already exists",
74
+ });
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ export async function updateYear(
81
+ userId: string,
82
+ input: {
83
+ schoolId: string;
84
+ yearId: string;
85
+ name?: string;
86
+ startDate?: Date;
87
+ endDate?: Date;
88
+ isCurrent?: boolean;
89
+ }
90
+ ) {
91
+ await requireSchoolAdmin(input.schoolId, userId);
92
+ const year = await updateAcademicYear(input.yearId, input.schoolId, {
93
+ name: input.name,
94
+ startDate: input.startDate,
95
+ endDate: input.endDate,
96
+ isCurrent: input.isCurrent,
97
+ });
98
+ if (!year) {
99
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
100
+ }
101
+ return { year };
102
+ }
103
+
104
+ export async function removeYear(
105
+ userId: string,
106
+ input: { schoolId: string; yearId: string }
107
+ ) {
108
+ await requireSchoolAdmin(input.schoolId, userId);
109
+ const result = await deleteAcademicYear(input.yearId, input.schoolId);
110
+ if (result.count === 0) {
111
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
112
+ }
113
+ return { ok: true as const };
114
+ }
115
+
116
+ export async function createTerm(
117
+ userId: string,
118
+ input: {
119
+ schoolId: string;
120
+ yearId: string;
121
+ name: string;
122
+ startDate: Date;
123
+ endDate: Date;
124
+ isCurrent?: boolean;
125
+ }
126
+ ) {
127
+ await requireSchoolAdmin(input.schoolId, userId);
128
+ const year = await getAcademicYearById(input.yearId, input.schoolId);
129
+ if (!year) {
130
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
131
+ }
132
+ if (input.endDate < input.startDate) {
133
+ throw new TRPCError({
134
+ code: "BAD_REQUEST",
135
+ message: "endDate must be on or after startDate",
136
+ });
137
+ }
138
+ try {
139
+ const term = await createAcademicTerm({
140
+ academicYearId: input.yearId,
141
+ name: input.name,
142
+ startDate: input.startDate,
143
+ endDate: input.endDate,
144
+ isCurrent: input.isCurrent,
145
+ });
146
+ return { term };
147
+ } catch (err: unknown) {
148
+ if (
149
+ err &&
150
+ typeof err === "object" &&
151
+ "code" in err &&
152
+ (err as { code: string }).code === "P2002"
153
+ ) {
154
+ throw new TRPCError({
155
+ code: "CONFLICT",
156
+ message: "A term with this name already exists in the year",
157
+ });
158
+ }
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ export async function updateTerm(
164
+ userId: string,
165
+ input: {
166
+ schoolId: string;
167
+ yearId: string;
168
+ termId: string;
169
+ name?: string;
170
+ startDate?: Date;
171
+ endDate?: Date;
172
+ isCurrent?: boolean;
173
+ }
174
+ ) {
175
+ await requireSchoolAdmin(input.schoolId, userId);
176
+ const year = await getAcademicYearById(input.yearId, input.schoolId);
177
+ if (!year) {
178
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
179
+ }
180
+ const term = await updateAcademicTerm(input.termId, input.yearId, {
181
+ name: input.name,
182
+ startDate: input.startDate,
183
+ endDate: input.endDate,
184
+ isCurrent: input.isCurrent,
185
+ });
186
+ if (!term) {
187
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic term not found" });
188
+ }
189
+ return { term };
190
+ }
191
+
192
+ export async function removeTerm(
193
+ userId: string,
194
+ input: { schoolId: string; yearId: string; termId: string }
195
+ ) {
196
+ await requireSchoolAdmin(input.schoolId, userId);
197
+ const year = await getAcademicYearById(input.yearId, input.schoolId);
198
+ if (!year) {
199
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
200
+ }
201
+ const result = await deleteAcademicTerm(input.termId, input.yearId);
202
+ if (result.count === 0) {
203
+ throw new TRPCError({ code: "NOT_FOUND", message: "Academic term not found" });
204
+ }
205
+ return { ok: true as const };
206
+ }
207
+
208
+ /**
209
+ * C2: Promote / move students from one class to another (basic rollover).
210
+ * Disconnects from `fromClassId` and connects to `toClassId`.
211
+ */
212
+ export async function promoteStudents(
213
+ userId: string,
214
+ input: {
215
+ schoolId: string;
216
+ fromClassId: string;
217
+ toClassId: string;
218
+ studentUserIds: string[];
219
+ }
220
+ ) {
221
+ await requireSchoolAdmin(input.schoolId, userId);
222
+
223
+ if (input.fromClassId === input.toClassId) {
224
+ throw new TRPCError({
225
+ code: "BAD_REQUEST",
226
+ message: "fromClassId and toClassId must differ",
227
+ });
228
+ }
229
+ if (input.studentUserIds.length === 0) {
230
+ throw new TRPCError({
231
+ code: "BAD_REQUEST",
232
+ message: "Select at least one student",
233
+ });
234
+ }
235
+
236
+ const [fromClass, toClass] = await Promise.all([
237
+ findSchoolClass(input.schoolId, input.fromClassId),
238
+ findSchoolClass(input.schoolId, input.toClassId),
239
+ ]);
240
+ if (!fromClass || !toClass) {
241
+ throw new TRPCError({
242
+ code: "NOT_FOUND",
243
+ message: "Source or destination class not found in this school",
244
+ });
245
+ }
246
+
247
+ const memberships = await listActiveSchoolMemberships(
248
+ input.schoolId,
249
+ input.studentUserIds
250
+ );
251
+ const studentIds = memberships
252
+ .filter((m) => m.role === "STUDENT")
253
+ .map((m) => m.userId);
254
+ if (studentIds.length === 0) {
255
+ throw new TRPCError({
256
+ code: "BAD_REQUEST",
257
+ message: "No active student memberships found for the given users",
258
+ });
259
+ }
260
+
261
+ // Only move students currently enrolled in the source class.
262
+ const enrolled = await prisma.class.findFirst({
263
+ where: {
264
+ id: input.fromClassId,
265
+ schoolId: input.schoolId,
266
+ },
267
+ select: {
268
+ students: {
269
+ where: { id: { in: studentIds } },
270
+ select: { id: true },
271
+ },
272
+ },
273
+ });
274
+ const moveIds = (enrolled?.students ?? []).map((s) => s.id);
275
+ if (moveIds.length === 0) {
276
+ throw new TRPCError({
277
+ code: "BAD_REQUEST",
278
+ message: "None of the selected students are in the source class",
279
+ });
280
+ }
281
+
282
+ await prisma.$transaction([
283
+ prisma.class.update({
284
+ where: { id: input.fromClassId },
285
+ data: { students: { disconnect: moveIds.map((id) => ({ id })) } },
286
+ }),
287
+ prisma.class.update({
288
+ where: { id: input.toClassId },
289
+ data: { students: { connect: moveIds.map((id) => ({ id })) } },
290
+ }),
291
+ ]);
292
+
293
+ return {
294
+ promotedCount: moveIds.length,
295
+ studentUserIds: moveIds,
296
+ fromClassId: input.fromClassId,
297
+ toClassId: input.toClassId,
298
+ };
299
+ }