@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
@@ -33,13 +33,42 @@ import {
33
33
  updateSchoolDetails,
34
34
  } from "../services/school.js";
35
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";
36
50
 
37
- const schoolRoleSchema = z.enum(["ADMIN", "TEACHER", "STUDENT"]);
51
+ const schoolRoleSchema = z.enum([
52
+ "ADMIN",
53
+ "TEACHER",
54
+ "STUDENT",
55
+ "ACCOUNTANT",
56
+ "PARENT",
57
+ ]);
38
58
 
39
59
  const schoolIdSchema = z.object({
40
60
  schoolId: z.string(),
41
61
  });
42
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
+
43
72
  export const schoolRouter = createTRPCRouter({
44
73
  getCurrent: protectedProcedure
45
74
  .input(z.object({ schoolId: z.string().optional() }).optional())
@@ -66,6 +95,7 @@ export const schoolRouter = createTRPCRouter({
66
95
  description: z.string().nullable().optional(),
67
96
  subdomain: z.string().nullable().optional(),
68
97
  logoId: z.string().nullable().optional(),
98
+ letterheadFileId: z.string().nullable().optional(),
69
99
  })
70
100
  )
71
101
  .mutation(({ ctx, input }) => updateSchoolDetails(ctx.user!.id, input)),
@@ -148,6 +178,66 @@ export const schoolRouter = createTRPCRouter({
148
178
  .input(schoolIdSchema)
149
179
  .query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
150
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
+
151
241
  getAiUsage: protectedProcedure
152
242
  .input(
153
243
  z.object({
@@ -228,6 +318,31 @@ export const schoolRouter = createTRPCRouter({
228
318
  .input(z.object({ schoolId: z.string(), memberUserId: z.string() }))
229
319
  .query(({ ctx, input }) => getMemberDetail(ctx.user!.id, input)),
230
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
+
231
346
  bulkUpdateMemberRoles: protectedProcedure
232
347
  .input(
233
348
  z.object({
@@ -247,6 +362,18 @@ export const schoolRouter = createTRPCRouter({
247
362
  )
248
363
  .mutation(({ ctx, input }) => bulkRemoveMembers(ctx.user!.id, input)),
249
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
+
250
377
  listInvites: protectedProcedure
251
378
  .input(schoolIdSchema)
252
379
  .query(({ ctx, input }) => listInvites(ctx.user!.id, input.schoolId)),
@@ -277,4 +404,15 @@ export const schoolRouter = createTRPCRouter({
277
404
  acceptInvite: protectedProcedure
278
405
  .input(z.object({ token: z.string() }))
279
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)),
280
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
+ }