@studious-lms/server 1.24.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 (83) hide show
  1. package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +4 -4
  2. package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1 -1
  3. package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +4 -4
  4. package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +4 -4
  5. package/dist/models/attendance.d.ts.map +1 -1
  6. package/dist/models/attendance.js +17 -14
  7. package/dist/models/attendance.js.map +1 -1
  8. package/dist/models/marketplace.d.ts +2 -0
  9. package/dist/models/marketplace.d.ts.map +1 -1
  10. package/dist/models/marketplace.js +3 -2
  11. package/dist/models/marketplace.js.map +1 -1
  12. package/dist/models/moduleItem.d.ts +5 -1
  13. package/dist/models/moduleItem.d.ts.map +1 -1
  14. package/dist/models/moduleItem.js +19 -4
  15. package/dist/models/moduleItem.js.map +1 -1
  16. package/dist/routers/_app.d.ts +8 -6
  17. package/dist/routers/_app.d.ts.map +1 -1
  18. package/dist/routers/fees.d.ts +1 -1
  19. package/dist/routers/moduleItem.d.ts.map +1 -1
  20. package/dist/routers/moduleItem.js +7 -6
  21. package/dist/routers/moduleItem.js.map +1 -1
  22. package/dist/routers/parent.d.ts +2 -1
  23. package/dist/routers/parent.d.ts.map +1 -1
  24. package/dist/routers/school.d.ts +1 -1
  25. package/dist/routers/school.d.ts.map +1 -1
  26. package/dist/routers/school.js +14 -4
  27. package/dist/routers/school.js.map +1 -1
  28. package/dist/services/academicCalendar.d.ts.map +1 -1
  29. package/dist/services/academicCalendar.js +30 -2
  30. package/dist/services/academicCalendar.js.map +1 -1
  31. package/dist/services/attendance.d.ts +6 -2
  32. package/dist/services/attendance.d.ts.map +1 -1
  33. package/dist/services/attendance.js +8 -4
  34. package/dist/services/attendance.js.map +1 -1
  35. package/dist/services/class.d.ts +14 -9
  36. package/dist/services/class.d.ts.map +1 -1
  37. package/dist/services/class.js +4 -4
  38. package/dist/services/class.js.map +1 -1
  39. package/dist/services/fees.d.ts.map +1 -1
  40. package/dist/services/fees.js +24 -4
  41. package/dist/services/fees.js.map +1 -1
  42. package/dist/services/feesAuthz.d.ts +2 -5
  43. package/dist/services/feesAuthz.d.ts.map +1 -1
  44. package/dist/services/feesAuthz.js +5 -8
  45. package/dist/services/feesAuthz.js.map +1 -1
  46. package/dist/services/marketplace.d.ts.map +1 -1
  47. package/dist/services/marketplace.js +8 -5
  48. package/dist/services/marketplace.js.map +1 -1
  49. package/dist/services/moduleItem.d.ts.map +1 -1
  50. package/dist/services/moduleItem.js +23 -7
  51. package/dist/services/moduleItem.js.map +1 -1
  52. package/dist/services/parentPortal.d.ts +3 -5
  53. package/dist/services/parentPortal.d.ts.map +1 -1
  54. package/dist/services/parentPortal.js +29 -43
  55. package/dist/services/parentPortal.js.map +1 -1
  56. package/dist/services/school.d.ts.map +1 -1
  57. package/dist/services/school.js +37 -13
  58. package/dist/services/school.js.map +1 -1
  59. package/dist/services/studentImport.d.ts.map +1 -1
  60. package/dist/services/studentImport.js +38 -25
  61. package/dist/services/studentImport.js.map +1 -1
  62. package/dist/services/transcript.d.ts +2 -3
  63. package/dist/services/transcript.d.ts.map +1 -1
  64. package/dist/services/transcript.js +13 -25
  65. package/dist/services/transcript.js.map +1 -1
  66. package/package.json +1 -1
  67. package/scripts/import-nepal-cdc-curriculum.ts +29 -6
  68. package/src/models/attendance.ts +10 -12
  69. package/src/models/marketplace.ts +3 -0
  70. package/src/models/moduleItem.ts +20 -2
  71. package/src/routers/moduleItem.ts +6 -4
  72. package/src/routers/school.ts +16 -6
  73. package/src/services/academicCalendar.ts +28 -0
  74. package/src/services/attendance.ts +7 -3
  75. package/src/services/class.ts +2 -2
  76. package/src/services/fees.ts +25 -2
  77. package/src/services/feesAuthz.ts +3 -7
  78. package/src/services/marketplace.ts +8 -3
  79. package/src/services/moduleItem.ts +28 -4
  80. package/src/services/parentPortal.ts +26 -39
  81. package/src/services/school.ts +37 -12
  82. package/src/services/studentImport.ts +37 -25
  83. package/src/services/transcript.ts +11 -23
@@ -39,6 +39,20 @@ async function requireAdmin(schoolId: string, userId: string) {
39
39
  return membership;
40
40
  }
41
41
 
42
+ async function assertActiveSchoolStudents(
43
+ schoolId: string,
44
+ studentIds: string[]
45
+ ) {
46
+ const activeIds = new Set(await listActiveStudentIds(schoolId));
47
+ const invalid = studentIds.filter((id) => !activeIds.has(id));
48
+ if (invalid.length > 0) {
49
+ throw new TRPCError({
50
+ code: "BAD_REQUEST",
51
+ message: "One or more students are not active members of this school",
52
+ });
53
+ }
54
+ }
55
+
42
56
  function assertMonth(month: number) {
43
57
  if (month < 1 || month > 12) {
44
58
  throw new TRPCError({
@@ -117,6 +131,7 @@ export async function createPlan(
117
131
  amountNpr: input.amountNpr,
118
132
  });
119
133
  if (input.studentIds?.length) {
134
+ await assertActiveSchoolStudents(input.schoolId, input.studentIds);
120
135
  const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
121
136
  return { plan: updated };
122
137
  }
@@ -162,6 +177,7 @@ export async function setPlanStudents(
162
177
  throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
163
178
  }
164
179
  await requireAdmin(existing.schoolId, userId);
180
+ await assertActiveSchoolStudents(existing.schoolId, input.studentIds);
165
181
  const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
166
182
  return { plan };
167
183
  }
@@ -192,7 +208,9 @@ export async function generateMonthlyInvoices(
192
208
  }
193
209
 
194
210
  let studentIds = input.studentIds;
195
- if (!studentIds?.length) {
211
+ if (studentIds?.length) {
212
+ await assertActiveSchoolStudents(input.schoolId, studentIds);
213
+ } else {
196
214
  const enrolled = await listPlanEnrollmentIds(plan.id);
197
215
  studentIds = enrolled.length
198
216
  ? enrolled
@@ -410,7 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
410
428
  },
411
429
  {
412
430
  label: "Paid at",
413
- value: payment.paidAt.toISOString().slice(0, 10),
431
+ value: new Intl.DateTimeFormat("en-CA", {
432
+ timeZone: "Asia/Kathmandu",
433
+ year: "numeric",
434
+ month: "2-digit",
435
+ day: "2-digit",
436
+ }).format(payment.paidAt),
414
437
  },
415
438
  ],
416
439
  footerNote: "Manual ledger receipt — gateway integration deferred.",
@@ -54,10 +54,7 @@ export function canManageFees(role: SchoolMembershipRole) {
54
54
  return FEE_WRITE_ROLES.includes(role);
55
55
  }
56
56
 
57
- /**
58
- * Placeholder procedure surface until A2 fee models exist.
59
- * Returns capability flags so the client can show/hide fee UI.
60
- */
57
+ /** Returns capability flags so the client can show/hide fee UI. */
61
58
  export async function getFeeCapabilities(userId: string, schoolId: string) {
62
59
  const membership = await findActiveSchoolMembership(schoolId, userId);
63
60
  if (!membership) {
@@ -70,8 +67,7 @@ export async function getFeeCapabilities(userId: string, schoolId: string) {
70
67
  role: membership.role,
71
68
  canManageFees: canManageFees(membership.role),
72
69
  canViewFees: FEE_READ_ROLES.includes(membership.role),
73
- feesImplemented: false as const,
74
- message:
75
- "Fee models are not implemented on this branch yet (owned by A2). Authz stubs are ready.",
70
+ feesImplemented: true as const,
71
+ message: "Fee features are available.",
76
72
  };
77
73
  }
@@ -146,14 +146,19 @@ export async function listMarketplace(
146
146
  }
147
147
  ) {
148
148
  const memberships = await listUserSchoolMemberships(userId);
149
+ if (
150
+ input.schoolId &&
151
+ !memberships.some((m) => m.schoolId === input.schoolId)
152
+ ) {
153
+ return { items: [], nextCursor: undefined };
154
+ }
149
155
  const schoolIds = input.schoolId
150
- ? memberships.some((m) => m.schoolId === input.schoolId)
151
- ? [input.schoolId]
152
- : []
156
+ ? [input.schoolId]
153
157
  : memberships.map((m) => m.schoolId);
154
158
 
155
159
  return searchMarketplaceListings({
156
160
  schoolIds,
161
+ scopeSchoolId: input.schoolId,
157
162
  type: input.type,
158
163
  search: input.search,
159
164
  tags: input.tags,
@@ -7,6 +7,7 @@ import { prisma } from "../lib/prisma.js";
7
7
  import {
8
8
  attachFilesToModuleItem,
9
9
  createModuleItem,
10
+ findAccessibleFilesByIds,
10
11
  deleteModuleItem,
11
12
  findModuleItemByIdForMember,
12
13
  findModuleItemByIdForTeacher,
@@ -33,6 +34,21 @@ function assertVideoFiles(
33
34
  }
34
35
  }
35
36
 
37
+ async function resolveExistingFiles(
38
+ type: ModuleItemType,
39
+ existingFileIds: string[],
40
+ userId: string
41
+ ) {
42
+ const accessible = await findAccessibleFilesByIds(existingFileIds, userId);
43
+ if (accessible.length !== new Set(existingFileIds).size) {
44
+ throw new TRPCError({
45
+ code: "FORBIDDEN",
46
+ message: "One or more files are not accessible",
47
+ });
48
+ }
49
+ assertVideoFiles(type, accessible);
50
+ }
51
+
36
52
  async function assertTeacherOwnsSection(sectionId: string, userId: string) {
37
53
  const section = await prisma.section.findFirst({
38
54
  where: {
@@ -142,6 +158,9 @@ export async function createModuleItemRecord(
142
158
  }
143
159
 
144
160
  assertVideoFiles(input.type, input.files);
161
+ if (input.existingFileIds?.length) {
162
+ await resolveExistingFiles(input.type, input.existingFileIds, userId);
163
+ }
145
164
 
146
165
  const maxOrder = await prisma.moduleItem.aggregate({
147
166
  where: { sectionId: input.sectionId },
@@ -159,7 +178,7 @@ export async function createModuleItemRecord(
159
178
  });
160
179
 
161
180
  if (input.existingFileIds?.length) {
162
- await attachFilesToModuleItem(created.id, input.existingFileIds);
181
+ await attachFilesToModuleItem(created.id, input.existingFileIds, userId);
163
182
  }
164
183
 
165
184
  let uploadFiles: Awaited<ReturnType<typeof createDirectUploadFiles>> = [];
@@ -173,7 +192,8 @@ export async function createModuleItemRecord(
173
192
  );
174
193
  await attachFilesToModuleItem(
175
194
  created.id,
176
- uploadFiles.map((f) => f.id)
195
+ uploadFiles.map((f) => f.id),
196
+ userId
177
197
  );
178
198
  }
179
199
 
@@ -205,6 +225,9 @@ export async function updateModuleItemRecord(
205
225
 
206
226
  const nextType = input.type ?? existing.type;
207
227
  assertVideoFiles(nextType, input.files);
228
+ if (input.existingFileIds?.length) {
229
+ await resolveExistingFiles(nextType, input.existingFileIds, userId);
230
+ }
208
231
 
209
232
  if (input.sectionId && input.sectionId !== existing.sectionId) {
210
233
  await assertTeacherOwnsSection(input.sectionId, userId);
@@ -220,7 +243,7 @@ export async function updateModuleItemRecord(
220
243
  });
221
244
 
222
245
  if (input.existingFileIds?.length) {
223
- await attachFilesToModuleItem(input.id, input.existingFileIds);
246
+ await attachFilesToModuleItem(input.id, input.existingFileIds, userId);
224
247
  }
225
248
 
226
249
  let uploadFiles: Awaited<ReturnType<typeof createDirectUploadFiles>> = [];
@@ -234,7 +257,8 @@ export async function updateModuleItemRecord(
234
257
  );
235
258
  await attachFilesToModuleItem(
236
259
  input.id,
237
- uploadFiles.map((f) => f.id)
260
+ uploadFiles.map((f) => f.id),
261
+ userId
238
262
  );
239
263
  }
240
264
 
@@ -208,10 +208,7 @@ async function assertParentOfStudent(
208
208
  return link;
209
209
  }
210
210
 
211
- /**
212
- * Read-only attendance stub for a linked child (aggregates class attendance M2M).
213
- * Does not touch A1 AttendanceRecord refactor.
214
- */
211
+ /** Read-only attendance view for a linked child (reads AttendanceRecord). */
215
212
  export async function getChildAttendance(
216
213
  userId: string,
217
214
  input: {
@@ -234,58 +231,48 @@ export async function getChildAttendance(
234
231
  });
235
232
  const classIds = classes.map((c) => c.id);
236
233
 
237
- const attendance = await prisma.attendance.findMany({
234
+ const attendanceRecords = await prisma.attendanceRecord.findMany({
238
235
  where: {
239
- classId: { in: classIds },
240
- ...(input.from || input.to
241
- ? {
242
- date: {
243
- ...(input.from ? { gte: input.from } : {}),
244
- ...(input.to ? { lte: input.to } : {}),
245
- },
246
- }
247
- : {}),
248
- OR: [
249
- { present: { some: { id: input.studentUserId } } },
250
- { late: { some: { id: input.studentUserId } } },
251
- { absent: { some: { id: input.studentUserId } } },
252
- ],
236
+ studentId: input.studentUserId,
237
+ attendance: {
238
+ classId: { in: classIds },
239
+ ...(input.from || input.to
240
+ ? {
241
+ date: {
242
+ ...(input.from ? { gte: input.from } : {}),
243
+ ...(input.to ? { lte: input.to } : {}),
244
+ },
245
+ }
246
+ : {}),
247
+ },
253
248
  },
254
249
  select: {
255
- id: true,
256
- date: true,
257
- classId: true,
258
- present: { where: { id: input.studentUserId }, select: { id: true } },
259
- late: { where: { id: input.studentUserId }, select: { id: true } },
260
- absent: { where: { id: input.studentUserId }, select: { id: true } },
250
+ status: true,
251
+ attendance: { select: { id: true, date: true, classId: true } },
261
252
  },
262
- orderBy: { date: "desc" },
253
+ orderBy: { attendance: { date: "desc" } },
263
254
  take: 200,
264
255
  });
265
256
 
266
257
  const classMap = new Map(classes.map((c) => [c.id, c]));
267
- const records = attendance.map((row) => {
268
- let status: "PRESENT" | "LATE" | "ABSENT" = "PRESENT";
269
- if (row.absent.length > 0) status = "ABSENT";
270
- else if (row.late.length > 0) status = "LATE";
271
- return {
272
- attendanceId: row.id,
273
- date: row.date,
274
- classId: row.classId,
275
- class: classMap.get(row.classId) ?? null,
276
- status,
277
- };
278
- });
258
+ const records = attendanceRecords.map((row) => ({
259
+ attendanceId: row.attendance.id,
260
+ date: row.attendance.date,
261
+ classId: row.attendance.classId,
262
+ class: classMap.get(row.attendance.classId) ?? null,
263
+ status: row.status,
264
+ }));
279
265
 
280
266
  const summary = records.reduce(
281
267
  (acc, r) => {
282
268
  acc.total += 1;
283
269
  if (r.status === "PRESENT") acc.present += 1;
284
270
  else if (r.status === "LATE") acc.late += 1;
271
+ else if (r.status === "EXCUSED") acc.excused += 1;
285
272
  else acc.absent += 1;
286
273
  return acc;
287
274
  },
288
- { present: 0, late: 0, absent: 0, total: 0 }
275
+ { present: 0, late: 0, absent: 0, excused: 0, total: 0 }
289
276
  );
290
277
 
291
278
  return { summary, records };
@@ -133,6 +133,28 @@ export async function updateSchoolDetails(
133
133
  }
134
134
  ) {
135
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
+ }
136
158
  const updated = await updateSchoolRecord(input.schoolId, {
137
159
  name: input.name,
138
160
  description: input.description,
@@ -628,17 +650,18 @@ export async function getStudentTimeline(
628
650
  });
629
651
  }
630
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);
631
656
  const range = {
632
- ...(input.from ? { gte: input.from } : {}),
657
+ gte: input.from ?? defaultFrom,
633
658
  ...(input.to ? { lte: input.to } : {}),
634
659
  };
635
- const hasRange = Object.keys(range).length > 0;
636
-
637
660
  const [events, assignments, attendance] = await Promise.all([
638
661
  prisma.event.findMany({
639
662
  where: {
640
663
  userId: input.studentId,
641
- ...(hasRange ? { startTime: range } : {}),
664
+ startTime: range,
642
665
  },
643
666
  select: {
644
667
  id: true,
@@ -658,7 +681,7 @@ export async function getStudentTimeline(
658
681
  students: { some: { id: input.studentId } },
659
682
  },
660
683
  template: false,
661
- ...(hasRange ? { dueDate: range } : {}),
684
+ dueDate: range,
662
685
  },
663
686
  select: {
664
687
  id: true,
@@ -695,7 +718,7 @@ export async function getStudentTimeline(
695
718
  studentId: input.studentId,
696
719
  attendance: {
697
720
  class: { schoolId: input.schoolId },
698
- ...(hasRange ? { date: range } : {}),
721
+ date: range,
699
722
  },
700
723
  },
701
724
  select: {
@@ -786,11 +809,13 @@ export async function getMemberTimeline(
786
809
  });
787
810
  }
788
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);
789
815
  const range = {
790
- ...(input.from ? { gte: input.from } : {}),
816
+ gte: input.from ?? defaultFrom,
791
817
  ...(input.to ? { lte: input.to } : {}),
792
818
  };
793
- const hasRange = Object.keys(range).length > 0;
794
819
 
795
820
  if (membership.role === "STUDENT") {
796
821
  if (!callerIsAdmin) {
@@ -814,7 +839,7 @@ export async function getMemberTimeline(
814
839
  prisma.event.findMany({
815
840
  where: {
816
841
  userId: input.memberId,
817
- ...(hasRange ? { startTime: range } : {}),
842
+ startTime: range,
818
843
  },
819
844
  select: {
820
845
  id: true,
@@ -832,7 +857,7 @@ export async function getMemberTimeline(
832
857
  where: {
833
858
  class: { students: { some: { id: input.memberId } } },
834
859
  template: false,
835
- ...(hasRange ? { dueDate: range } : {}),
860
+ dueDate: range,
836
861
  },
837
862
  select: {
838
863
  id: true,
@@ -869,7 +894,7 @@ export async function getMemberTimeline(
869
894
  studentId: input.memberId,
870
895
  attendance: {
871
896
  class: { schoolId: input.schoolId },
872
- ...(hasRange ? { date: range } : {}),
897
+ date: range,
873
898
  },
874
899
  },
875
900
  select: {
@@ -925,7 +950,7 @@ export async function getMemberTimeline(
925
950
  { userId: input.memberId },
926
951
  { class: { teachers: { some: { id: input.memberId } } } },
927
952
  ],
928
- ...(hasRange ? { startTime: range } : {}),
953
+ startTime: range,
929
954
  },
930
955
  select: {
931
956
  id: true,
@@ -11,9 +11,8 @@ import { randomBytes } from "crypto";
11
11
  import { hash } from "bcryptjs";
12
12
  import { TRPCError } from "@trpc/server";
13
13
  import { prisma } from "../lib/prisma.js";
14
- import { createUser, findUserByEmail, findUserByUsername } from "../models/auth.js";
15
- import { findActiveSchoolAdminMembership, upsertSchoolMembership } from "../models/school.js";
16
- import { upsertUserProfile } from "../models/user.js";
14
+ import { findUserByEmail, findUserByUsername } from "../models/auth.js";
15
+ import { findActiveSchoolAdminMembership } from "../models/school.js";
17
16
  import { generateUniqueUsername } from "../utils/generateUsername.js";
18
17
 
19
18
  export type StudentCsvRowResult =
@@ -140,8 +139,7 @@ export async function importStudentsCsv(
140
139
  for (let i = 0; i < rows.length; i += 1) {
141
140
  const rowNum = i + 2; // 1-indexed + header
142
141
  const row = rows[i]!;
143
- const displayName =
144
- row.displayname || row.name || row.fullname || row.fullname || "";
142
+ const displayName = row.displayname || row.name || row.fullname || "";
145
143
  const requestedUsername = row.username || "";
146
144
  const requestedEmail = (row.email || "").toLowerCase();
147
145
  const requestedPassword = row.password || "";
@@ -215,29 +213,43 @@ export async function importStudentsCsv(
215
213
  }
216
214
 
217
215
  const hashed = await hash(password, 10);
218
- const user = await createUser({
219
- username,
220
- email,
221
- password: hashed,
222
- verified: true,
223
- });
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
+ });
224
227
 
225
- if (displayName) {
226
- await upsertUserProfile(user.id, { displayName });
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
+ });
228
243
 
229
- await upsertSchoolMembership({
230
- schoolId: input.schoolId,
231
- userId: user.id,
232
- role: "STUDENT",
233
- });
244
+ if (classId) {
245
+ await tx.class.update({
246
+ where: { id: classId },
247
+ data: { students: { connect: { id: createdUser.id } } },
248
+ });
249
+ }
234
250
 
235
- if (classId) {
236
- await prisma.class.update({
237
- where: { id: classId },
238
- data: { students: { connect: { id: user.id } } },
239
- });
240
- }
251
+ return createdUser;
252
+ });
241
253
 
242
254
  results.push({
243
255
  ok: true,
@@ -8,9 +8,8 @@
8
8
  * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or
9
9
  * a free-text `schoolYear` string for the report header.
10
10
  *
11
- * Attendance is read via the existing present/late/absent M2M lists only
12
- * (same shaping as getStudentTimeline). Do not conflict with A1 Attendance
13
- * refactor — rate math lives in attendanceRate.ts.
11
+ * Attendance is read from AttendanceRecord (EXCUSED excluded from the rate
12
+ * denominator) — rate math lives in attendanceRate.ts.
14
13
  */
15
14
 
16
15
  import { TRPCError } from "@trpc/server";
@@ -93,30 +92,19 @@ async function fetchStudentAttendance(
93
92
  };
94
93
  const hasRange = Object.keys(range).length > 0;
95
94
 
96
- // Same M2M present/late/absent read path as getStudentTimeline (A1-safe).
97
- const attendance = await prisma.attendance.findMany({
95
+ // Read AttendanceRecord so EXCUSED records are excluded from the rate
96
+ // denominator (computeAttendanceRate ignores non-PRESENT/LATE/ABSENT).
97
+ const records = await prisma.attendanceRecord.findMany({
98
98
  where: {
99
- OR: [
100
- { present: { some: { id: studentId } } },
101
- { late: { some: { id: studentId } } },
102
- { absent: { some: { id: studentId } } },
103
- ],
104
- ...(hasRange ? { date: range } : {}),
105
- },
106
- select: {
107
- id: true,
108
- present: { where: { id: studentId }, select: { id: true } },
109
- late: { where: { id: studentId }, select: { id: true } },
110
- absent: { where: { id: studentId }, select: { id: true } },
99
+ studentId,
100
+ status: { in: ["PRESENT", "LATE", "ABSENT"] },
101
+ ...(hasRange ? { attendance: { date: range } } : {}),
111
102
  },
103
+ select: { status: true },
112
104
  });
113
105
 
114
- return attendance.map((record) => ({
115
- status: record.present.length
116
- ? ("PRESENT" as const)
117
- : record.late.length
118
- ? ("LATE" as const)
119
- : ("ABSENT" as const),
106
+ return records.map((record) => ({
107
+ status: record.status as "PRESENT" | "LATE" | "ABSENT",
120
108
  }));
121
109
  }
122
110