@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.
- package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1 -1
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +4 -4
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -14
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/marketplace.d.ts +2 -0
- package/dist/models/marketplace.d.ts.map +1 -1
- package/dist/models/marketplace.js +3 -2
- package/dist/models/marketplace.js.map +1 -1
- package/dist/models/moduleItem.d.ts +5 -1
- package/dist/models/moduleItem.d.ts.map +1 -1
- package/dist/models/moduleItem.js +19 -4
- package/dist/models/moduleItem.js.map +1 -1
- package/dist/routers/_app.d.ts +8 -6
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/fees.d.ts +1 -1
- package/dist/routers/moduleItem.d.ts.map +1 -1
- package/dist/routers/moduleItem.js +7 -6
- package/dist/routers/moduleItem.js.map +1 -1
- package/dist/routers/parent.d.ts +2 -1
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/school.d.ts +1 -1
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +14 -4
- package/dist/routers/school.js.map +1 -1
- package/dist/services/academicCalendar.d.ts.map +1 -1
- package/dist/services/academicCalendar.js +30 -2
- package/dist/services/academicCalendar.js.map +1 -1
- package/dist/services/attendance.d.ts +6 -2
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +8 -4
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/class.d.ts +14 -9
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +4 -4
- package/dist/services/class.js.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +24 -4
- package/dist/services/fees.js.map +1 -1
- package/dist/services/feesAuthz.d.ts +2 -5
- package/dist/services/feesAuthz.d.ts.map +1 -1
- package/dist/services/feesAuthz.js +5 -8
- package/dist/services/feesAuthz.js.map +1 -1
- package/dist/services/marketplace.d.ts.map +1 -1
- package/dist/services/marketplace.js +8 -5
- package/dist/services/marketplace.js.map +1 -1
- package/dist/services/moduleItem.d.ts.map +1 -1
- package/dist/services/moduleItem.js +23 -7
- package/dist/services/moduleItem.js.map +1 -1
- package/dist/services/parentPortal.d.ts +3 -5
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +29 -43
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +37 -13
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts.map +1 -1
- package/dist/services/studentImport.js +38 -25
- package/dist/services/studentImport.js.map +1 -1
- package/dist/services/transcript.d.ts +2 -3
- package/dist/services/transcript.d.ts.map +1 -1
- package/dist/services/transcript.js +13 -25
- package/dist/services/transcript.js.map +1 -1
- package/package.json +1 -1
- package/scripts/import-nepal-cdc-curriculum.ts +29 -6
- package/src/models/attendance.ts +10 -12
- package/src/models/marketplace.ts +3 -0
- package/src/models/moduleItem.ts +20 -2
- package/src/routers/moduleItem.ts +6 -4
- package/src/routers/school.ts +16 -6
- package/src/services/academicCalendar.ts +28 -0
- package/src/services/attendance.ts +7 -3
- package/src/services/class.ts +2 -2
- package/src/services/fees.ts +25 -2
- package/src/services/feesAuthz.ts +3 -7
- package/src/services/marketplace.ts +8 -3
- package/src/services/moduleItem.ts +28 -4
- package/src/services/parentPortal.ts +26 -39
- package/src/services/school.ts +37 -12
- package/src/services/studentImport.ts +37 -25
- package/src/services/transcript.ts +11 -23
|
@@ -8,14 +8,13 @@
|
|
|
8
8
|
* placeholder local-part is used so the User.email uniqueness constraint holds.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
11
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="92478865-5731-54e7-88bf-a4d20ad22e07")}catch(e){}}();
|
|
12
12
|
import { randomBytes } from "crypto";
|
|
13
13
|
import { hash } from "bcryptjs";
|
|
14
14
|
import { TRPCError } from "@trpc/server";
|
|
15
15
|
import { prisma } from "../lib/prisma.js";
|
|
16
|
-
import {
|
|
17
|
-
import { findActiveSchoolAdminMembership
|
|
18
|
-
import { upsertUserProfile } from "../models/user.js";
|
|
16
|
+
import { findUserByEmail, findUserByUsername } from "../models/auth.js";
|
|
17
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
19
18
|
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
20
19
|
function parseCsvLine(line) {
|
|
21
20
|
const cells = [];
|
|
@@ -111,7 +110,7 @@ export async function importStudentsCsv(userId, input) {
|
|
|
111
110
|
for (let i = 0; i < rows.length; i += 1) {
|
|
112
111
|
const rowNum = i + 2; // 1-indexed + header
|
|
113
112
|
const row = rows[i];
|
|
114
|
-
const displayName = row.displayname || row.name || row.fullname ||
|
|
113
|
+
const displayName = row.displayname || row.name || row.fullname || "";
|
|
115
114
|
const requestedUsername = row.username || "";
|
|
116
115
|
const requestedEmail = (row.email || "").toLowerCase();
|
|
117
116
|
const requestedPassword = row.password || "";
|
|
@@ -180,26 +179,40 @@ export async function importStudentsCsv(userId, input) {
|
|
|
180
179
|
continue;
|
|
181
180
|
}
|
|
182
181
|
const hashed = await hash(password, 10);
|
|
183
|
-
const user = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
schoolId: input.schoolId,
|
|
194
|
-
userId: user.id,
|
|
195
|
-
role: "STUDENT",
|
|
196
|
-
});
|
|
197
|
-
if (classId) {
|
|
198
|
-
await prisma.class.update({
|
|
199
|
-
where: { id: classId },
|
|
200
|
-
data: { students: { connect: { id: user.id } } },
|
|
182
|
+
const user = await prisma.$transaction(async (tx) => {
|
|
183
|
+
const createdUser = await tx.user.create({
|
|
184
|
+
data: {
|
|
185
|
+
username,
|
|
186
|
+
email,
|
|
187
|
+
password: hashed,
|
|
188
|
+
verified: true,
|
|
189
|
+
profile: { create: displayName ? { displayName } : {} },
|
|
190
|
+
},
|
|
191
|
+
select: { id: true, username: true, email: true },
|
|
201
192
|
});
|
|
202
|
-
|
|
193
|
+
await tx.schoolMembership.upsert({
|
|
194
|
+
where: {
|
|
195
|
+
schoolId_userId: {
|
|
196
|
+
schoolId: input.schoolId,
|
|
197
|
+
userId: createdUser.id,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
update: { role: "STUDENT", status: "ACTIVE" },
|
|
201
|
+
create: {
|
|
202
|
+
schoolId: input.schoolId,
|
|
203
|
+
userId: createdUser.id,
|
|
204
|
+
role: "STUDENT",
|
|
205
|
+
status: "ACTIVE",
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (classId) {
|
|
209
|
+
await tx.class.update({
|
|
210
|
+
where: { id: classId },
|
|
211
|
+
data: { students: { connect: { id: createdUser.id } } },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return createdUser;
|
|
215
|
+
});
|
|
203
216
|
results.push({
|
|
204
217
|
ok: true,
|
|
205
218
|
row: rowNum,
|
|
@@ -233,4 +246,4 @@ export async function importStudentsCsv(userId, input) {
|
|
|
233
246
|
};
|
|
234
247
|
}
|
|
235
248
|
//# sourceMappingURL=studentImport.js.map
|
|
236
|
-
//# debugId=
|
|
249
|
+
//# debugId=92478865-5731-54e7-88bf-a4d20ad22e07
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"studentImport.js","sources":["services/studentImport.ts"],"sourceRoot":"/","sourcesContent":["/**\n * C3: Bulk student CSV onboarding under school admin.\n *\n * CSV columns (header row required):\n * displayName, username?, email?, password?, classId?\n *\n * Username/password are generated when omitted. Email is optional — a\n * placeholder local-part is used so the User.email uniqueness constraint holds.\n */\nimport { randomBytes } from \"crypto\";\nimport { hash } from \"bcryptjs\";\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { createUser, findUserByEmail, findUserByUsername } from \"../models/auth.js\";\nimport { findActiveSchoolAdminMembership, upsertSchoolMembership } from \"../models/school.js\";\nimport { upsertUserProfile } from \"../models/user.js\";\nimport { generateUniqueUsername } from \"../utils/generateUsername.js\";\n\nexport type StudentCsvRowResult =\n | {\n ok: true;\n row: number;\n userId: string;\n username: string;\n email: string;\n password: string;\n displayName: string | null;\n classId: string | null;\n generatedUsername: boolean;\n generatedPassword: boolean;\n placeholderEmail: boolean;\n }\n | {\n ok: false;\n row: number;\n error: string;\n raw?: string;\n };\n\nfunction parseCsvLine(line: string): string[] {\n const cells: string[] = [];\n let current = \"\";\n let inQuotes = false;\n for (let i = 0; i < line.length; i += 1) {\n const ch = line[i];\n if (inQuotes) {\n if (ch === '\"') {\n if (line[i + 1] === '\"') {\n current += '\"';\n i += 1;\n } else {\n inQuotes = false;\n }\n } else {\n current += ch;\n }\n } else if (ch === '\"') {\n inQuotes = true;\n } else if (ch === \",\") {\n cells.push(current.trim());\n current = \"\";\n } else {\n current += ch;\n }\n }\n cells.push(current.trim());\n return cells;\n}\n\nfunction normalizeHeader(h: string) {\n return h.trim().toLowerCase().replace(/[\\s_]+/g, \"\");\n}\n\nfunction generatePassword(length = 12) {\n const alphabet = \"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789\";\n const bytes = randomBytes(length);\n let out = \"\";\n for (let i = 0; i < length; i += 1) {\n out += alphabet[bytes[i]! % alphabet.length];\n }\n return out;\n}\n\nfunction placeholderEmail(username: string) {\n const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, \"\");\n return `${safe || \"student\"}.${randomBytes(3).toString(\"hex\")}@noemail.studious.local`;\n}\n\nexport function parseStudentCsv(csvText: string): {\n headers: string[];\n rows: Record<string, string>[];\n} {\n const lines = csvText\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n if (lines.length < 2) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"CSV must include a header row and at least one data row\",\n });\n }\n const headers = parseCsvLine(lines[0]!).map(normalizeHeader);\n const rows = lines.slice(1).map((line) => {\n const cells = parseCsvLine(line);\n const row: Record<string, string> = {};\n headers.forEach((h, i) => {\n row[h] = cells[i] ?? \"\";\n });\n return row;\n });\n return { headers, rows };\n}\n\nexport async function importStudentsCsv(\n userId: string,\n input: { schoolId: string; csvText: string; dryRun?: boolean }\n) {\n const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can perform this action\",\n });\n }\n\n const { rows } = parseStudentCsv(input.csvText);\n if (rows.length > 500) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"CSV import is limited to 500 rows per request\",\n });\n }\n\n const results: StudentCsvRowResult[] = [];\n let created = 0;\n let failed = 0;\n\n for (let i = 0; i < rows.length; i += 1) {\n const rowNum = i + 2; // 1-indexed + header\n const row = rows[i]!;\n const displayName =\n row.displayname || row.name || row.fullname || row.fullname || \"\";\n const requestedUsername = row.username || \"\";\n const requestedEmail = (row.email || \"\").toLowerCase();\n const requestedPassword = row.password || \"\";\n const classId = row.classid || row.class || \"\";\n\n try {\n if (!displayName && !requestedUsername && !requestedEmail) {\n throw new Error(\"Row needs displayName, username, or email\");\n }\n\n let username = requestedUsername;\n let generatedUsername = false;\n if (!username) {\n username = await generateUniqueUsername(async (candidate) => {\n const existing = await findUserByUsername(candidate);\n return Boolean(existing);\n });\n generatedUsername = true;\n } else {\n const taken = await findUserByUsername(username);\n if (taken) {\n throw new Error(`Username \"${username}\" already taken`);\n }\n }\n\n let email = requestedEmail;\n let placeholderEmailUsed = false;\n if (!email) {\n email = placeholderEmail(username);\n placeholderEmailUsed = true;\n } else {\n const existing = await findUserByEmail(email);\n if (existing) {\n throw new Error(`Email \"${email}\" already in use`);\n }\n }\n\n let password = requestedPassword;\n let generatedPassword = false;\n if (!password) {\n password = generatePassword();\n generatedPassword = true;\n }\n\n if (classId) {\n const cls = await prisma.class.findFirst({\n where: { id: classId, schoolId: input.schoolId },\n select: { id: true },\n });\n if (!cls) {\n throw new Error(`classId \"${classId}\" not found in this school`);\n }\n }\n\n if (input.dryRun) {\n results.push({\n ok: true,\n row: rowNum,\n userId: \"dry-run\",\n username,\n email,\n password,\n displayName: displayName || null,\n classId: classId || null,\n generatedUsername,\n generatedPassword,\n placeholderEmail: placeholderEmailUsed,\n });\n created += 1;\n continue;\n }\n\n const hashed = await hash(password, 10);\n const user = await createUser({\n username,\n email,\n password: hashed,\n verified: true,\n });\n\n if (displayName) {\n await upsertUserProfile(user.id, { displayName });\n }\n\n await upsertSchoolMembership({\n schoolId: input.schoolId,\n userId: user.id,\n role: \"STUDENT\",\n });\n\n if (classId) {\n await prisma.class.update({\n where: { id: classId },\n data: { students: { connect: { id: user.id } } },\n });\n }\n\n results.push({\n ok: true,\n row: rowNum,\n userId: user.id,\n username: user.username,\n email: user.email,\n password,\n displayName: displayName || null,\n classId: classId || null,\n generatedUsername,\n generatedPassword,\n placeholderEmail: placeholderEmailUsed,\n });\n created += 1;\n } catch (err) {\n failed += 1;\n results.push({\n ok: false,\n row: rowNum,\n error: err instanceof Error ? err.message : \"Unknown error\",\n });\n }\n }\n\n return {\n dryRun: Boolean(input.dryRun),\n created,\n failed,\n total: rows.length,\n results,\n };\n}\n"],"names":[],"mappings":"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","debug_id":"ce1c4284-8c8b-5ddd-bed4-ff674956801e"}
|
|
1
|
+
{"version":3,"file":"studentImport.js","sources":["services/studentImport.ts"],"sourceRoot":"/","sourcesContent":["/**\n * C3: Bulk student CSV onboarding under school admin.\n *\n * CSV columns (header row required):\n * displayName, username?, email?, password?, classId?\n *\n * Username/password are generated when omitted. Email is optional — a\n * placeholder local-part is used so the User.email uniqueness constraint holds.\n */\nimport { randomBytes } from \"crypto\";\nimport { hash } from \"bcryptjs\";\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { findUserByEmail, findUserByUsername } from \"../models/auth.js\";\nimport { findActiveSchoolAdminMembership } from \"../models/school.js\";\nimport { generateUniqueUsername } from \"../utils/generateUsername.js\";\n\nexport type StudentCsvRowResult =\n | {\n ok: true;\n row: number;\n userId: string;\n username: string;\n email: string;\n password: string;\n displayName: string | null;\n classId: string | null;\n generatedUsername: boolean;\n generatedPassword: boolean;\n placeholderEmail: boolean;\n }\n | {\n ok: false;\n row: number;\n error: string;\n raw?: string;\n };\n\nfunction parseCsvLine(line: string): string[] {\n const cells: string[] = [];\n let current = \"\";\n let inQuotes = false;\n for (let i = 0; i < line.length; i += 1) {\n const ch = line[i];\n if (inQuotes) {\n if (ch === '\"') {\n if (line[i + 1] === '\"') {\n current += '\"';\n i += 1;\n } else {\n inQuotes = false;\n }\n } else {\n current += ch;\n }\n } else if (ch === '\"') {\n inQuotes = true;\n } else if (ch === \",\") {\n cells.push(current.trim());\n current = \"\";\n } else {\n current += ch;\n }\n }\n cells.push(current.trim());\n return cells;\n}\n\nfunction normalizeHeader(h: string) {\n return h.trim().toLowerCase().replace(/[\\s_]+/g, \"\");\n}\n\nfunction generatePassword(length = 12) {\n const alphabet = \"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789\";\n const bytes = randomBytes(length);\n let out = \"\";\n for (let i = 0; i < length; i += 1) {\n out += alphabet[bytes[i]! % alphabet.length];\n }\n return out;\n}\n\nfunction placeholderEmail(username: string) {\n const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, \"\");\n return `${safe || \"student\"}.${randomBytes(3).toString(\"hex\")}@noemail.studious.local`;\n}\n\nexport function parseStudentCsv(csvText: string): {\n headers: string[];\n rows: Record<string, string>[];\n} {\n const lines = csvText\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n if (lines.length < 2) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"CSV must include a header row and at least one data row\",\n });\n }\n const headers = parseCsvLine(lines[0]!).map(normalizeHeader);\n const rows = lines.slice(1).map((line) => {\n const cells = parseCsvLine(line);\n const row: Record<string, string> = {};\n headers.forEach((h, i) => {\n row[h] = cells[i] ?? \"\";\n });\n return row;\n });\n return { headers, rows };\n}\n\nexport async function importStudentsCsv(\n userId: string,\n input: { schoolId: string; csvText: string; dryRun?: boolean }\n) {\n const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can perform this action\",\n });\n }\n\n const { rows } = parseStudentCsv(input.csvText);\n if (rows.length > 500) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"CSV import is limited to 500 rows per request\",\n });\n }\n\n const results: StudentCsvRowResult[] = [];\n let created = 0;\n let failed = 0;\n\n for (let i = 0; i < rows.length; i += 1) {\n const rowNum = i + 2; // 1-indexed + header\n const row = rows[i]!;\n const displayName = row.displayname || row.name || row.fullname || \"\";\n const requestedUsername = row.username || \"\";\n const requestedEmail = (row.email || \"\").toLowerCase();\n const requestedPassword = row.password || \"\";\n const classId = row.classid || row.class || \"\";\n\n try {\n if (!displayName && !requestedUsername && !requestedEmail) {\n throw new Error(\"Row needs displayName, username, or email\");\n }\n\n let username = requestedUsername;\n let generatedUsername = false;\n if (!username) {\n username = await generateUniqueUsername(async (candidate) => {\n const existing = await findUserByUsername(candidate);\n return Boolean(existing);\n });\n generatedUsername = true;\n } else {\n const taken = await findUserByUsername(username);\n if (taken) {\n throw new Error(`Username \"${username}\" already taken`);\n }\n }\n\n let email = requestedEmail;\n let placeholderEmailUsed = false;\n if (!email) {\n email = placeholderEmail(username);\n placeholderEmailUsed = true;\n } else {\n const existing = await findUserByEmail(email);\n if (existing) {\n throw new Error(`Email \"${email}\" already in use`);\n }\n }\n\n let password = requestedPassword;\n let generatedPassword = false;\n if (!password) {\n password = generatePassword();\n generatedPassword = true;\n }\n\n if (classId) {\n const cls = await prisma.class.findFirst({\n where: { id: classId, schoolId: input.schoolId },\n select: { id: true },\n });\n if (!cls) {\n throw new Error(`classId \"${classId}\" not found in this school`);\n }\n }\n\n if (input.dryRun) {\n results.push({\n ok: true,\n row: rowNum,\n userId: \"dry-run\",\n username,\n email,\n password,\n displayName: displayName || null,\n classId: classId || null,\n generatedUsername,\n generatedPassword,\n placeholderEmail: placeholderEmailUsed,\n });\n created += 1;\n continue;\n }\n\n const hashed = await hash(password, 10);\n const user = await prisma.$transaction(async (tx) => {\n const createdUser = await tx.user.create({\n data: {\n username,\n email,\n password: hashed,\n verified: true,\n profile: { create: displayName ? { displayName } : {} },\n },\n select: { id: true, username: true, email: true },\n });\n\n await tx.schoolMembership.upsert({\n where: {\n schoolId_userId: {\n schoolId: input.schoolId,\n userId: createdUser.id,\n },\n },\n update: { role: \"STUDENT\", status: \"ACTIVE\" },\n create: {\n schoolId: input.schoolId,\n userId: createdUser.id,\n role: \"STUDENT\",\n status: \"ACTIVE\",\n },\n });\n\n if (classId) {\n await tx.class.update({\n where: { id: classId },\n data: { students: { connect: { id: createdUser.id } } },\n });\n }\n\n return createdUser;\n });\n\n results.push({\n ok: true,\n row: rowNum,\n userId: user.id,\n username: user.username,\n email: user.email,\n password,\n displayName: displayName || null,\n classId: classId || null,\n generatedUsername,\n generatedPassword,\n placeholderEmail: placeholderEmailUsed,\n });\n created += 1;\n } catch (err) {\n failed += 1;\n results.push({\n ok: false,\n row: rowNum,\n error: err instanceof Error ? err.message : \"Unknown error\",\n });\n }\n }\n\n return {\n dryRun: Boolean(input.dryRun),\n created,\n failed,\n total: rows.length,\n results,\n };\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;;;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,+BAA+B,EAAE,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAuBtE,SAAS,YAAY,CAAC,IAAY;IAChC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACnB,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;gBACf,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;oBACxB,OAAO,IAAI,GAAG,CAAC;oBACf,CAAC,IAAI,CAAC,CAAC;gBACT,CAAC;qBAAM,CAAC;oBACN,QAAQ,GAAG,KAAK,CAAC;gBACnB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,OAAO,IAAI,EAAE,CAAC;YAChB,CAAC;QACH,CAAC;aAAM,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACtB,QAAQ,GAAG,IAAI,CAAC;QAClB,CAAC;aAAM,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACtB,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;YAC3B,OAAO,GAAG,EAAE,CAAC;QACf,CAAC;aAAM,CAAC;YACN,OAAO,IAAI,EAAE,CAAC;QAChB,CAAC;IACH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAC3B,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,eAAe,CAAC,CAAS;IAChC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;AACvD,CAAC;AAED,SAAS,gBAAgB,CAAC,MAAM,GAAG,EAAE;IACnC,MAAM,QAAQ,GAAG,2DAA2D,CAAC;IAC7E,MAAM,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACnC,GAAG,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,gBAAgB,CAAC,QAAgB;IACxC,MAAM,IAAI,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC;IACjE,OAAO,GAAG,IAAI,IAAI,SAAS,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,yBAAyB,CAAC;AACzF,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,OAAe;IAI7C,MAAM,KAAK,GAAG,OAAO;SAClB,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC;SACtB,KAAK,CAAC,OAAO,CAAC;SACd,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC/B,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,aAAa;YACnB,OAAO,EAAE,yDAAyD;SACnE,CAAC,CAAC;IACL,CAAC;IACD,MAAM,OAAO,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,CAAE,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAC7D,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACvC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,GAAG,GAA2B,EAAE,CAAC;QACvC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;YACvB,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC1B,CAAC,CAAC,CAAC;QACH,OAAO,GAAG,CAAC;IACb,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC3B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,MAAc,EACd,KAA8D;IAE9D,MAAM,UAAU,GAAG,MAAM,+BAA+B,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACjF,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,4CAA4C;SACtD,CAAC,CAAC;IACL,CAAC;IAED,MAAM,EAAE,IAAI,EAAE,GAAG,eAAe,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAChD,IAAI,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QACtB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,aAAa;YACnB,OAAO,EAAE,+CAA+C;SACzD,CAAC,CAAC;IACL,CAAC;IAED,MAAM,OAAO,GAA0B,EAAE,CAAC;IAC1C,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,qBAAqB;QAC3C,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAE,CAAC;QACrB,MAAM,WAAW,GAAG,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC;QACtE,MAAM,iBAAiB,GAAG,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC;QAC7C,MAAM,cAAc,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACvD,MAAM,iBAAiB,GAAG,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC;QAC7C,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;QAE/C,IAAI,CAAC;YACH,IAAI,CAAC,WAAW,IAAI,CAAC,iBAAiB,IAAI,CAAC,cAAc,EAAE,CAAC;gBAC1D,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;YAC/D,CAAC;YAED,IAAI,QAAQ,GAAG,iBAAiB,CAAC;YACjC,IAAI,iBAAiB,GAAG,KAAK,CAAC;YAC9B,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,QAAQ,GAAG,MAAM,sBAAsB,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE;oBAC1D,MAAM,QAAQ,GAAG,MAAM,kBAAkB,CAAC,SAAS,CAAC,CAAC;oBACrD,OAAO,OAAO,CAAC,QAAQ,CAAC,CAAC;gBAC3B,CAAC,CAAC,CAAC;gBACH,iBAAiB,GAAG,IAAI,CAAC;YAC3B,CAAC;iBAAM,CAAC;gBACN,MAAM,KAAK,GAAG,MAAM,kBAAkB,CAAC,QAAQ,CAAC,CAAC;gBACjD,IAAI,KAAK,EAAE,CAAC;oBACV,MAAM,IAAI,KAAK,CAAC,aAAa,QAAQ,iBAAiB,CAAC,CAAC;gBAC1D,CAAC;YACH,CAAC;YAED,IAAI,KAAK,GAAG,cAAc,CAAC;YAC3B,IAAI,oBAAoB,GAAG,KAAK,CAAC;YACjC,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,KAAK,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;gBACnC,oBAAoB,GAAG,IAAI,CAAC;YAC9B,CAAC;iBAAM,CAAC;gBACN,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,KAAK,CAAC,CAAC;gBAC9C,IAAI,QAAQ,EAAE,CAAC;oBACb,MAAM,IAAI,KAAK,CAAC,UAAU,KAAK,kBAAkB,CAAC,CAAC;gBACrD,CAAC;YACH,CAAC;YAED,IAAI,QAAQ,GAAG,iBAAiB,CAAC;YACjC,IAAI,iBAAiB,GAAG,KAAK,CAAC;YAC9B,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,QAAQ,GAAG,gBAAgB,EAAE,CAAC;gBAC9B,iBAAiB,GAAG,IAAI,CAAC;YAC3B,CAAC;YAED,IAAI,OAAO,EAAE,CAAC;gBACZ,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC;oBACvC,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE;oBAChD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE;iBACrB,CAAC,CAAC;gBACH,IAAI,CAAC,GAAG,EAAE,CAAC;oBACT,MAAM,IAAI,KAAK,CAAC,YAAY,OAAO,4BAA4B,CAAC,CAAC;gBACnE,CAAC;YACH,CAAC;YAED,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;gBACjB,OAAO,CAAC,IAAI,CAAC;oBACX,EAAE,EAAE,IAAI;oBACR,GAAG,EAAE,MAAM;oBACX,MAAM,EAAE,SAAS;oBACjB,QAAQ;oBACR,KAAK;oBACL,QAAQ;oBACR,WAAW,EAAE,WAAW,IAAI,IAAI;oBAChC,OAAO,EAAE,OAAO,IAAI,IAAI;oBACxB,iBAAiB;oBACjB,iBAAiB;oBACjB,gBAAgB,EAAE,oBAAoB;iBACvC,CAAC,CAAC;gBACH,OAAO,IAAI,CAAC,CAAC;gBACb,SAAS;YACX,CAAC;YAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;YACxC,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;gBAClD,MAAM,WAAW,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC;oBACvC,IAAI,EAAE;wBACJ,QAAQ;wBACR,KAAK;wBACL,QAAQ,EAAE,MAAM;wBAChB,QAAQ,EAAE,IAAI;wBACd,OAAO,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE;qBACxD;oBACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;iBAClD,CAAC,CAAC;gBAEH,MAAM,EAAE,CAAC,gBAAgB,CAAC,MAAM,CAAC;oBAC/B,KAAK,EAAE;wBACL,eAAe,EAAE;4BACf,QAAQ,EAAE,KAAK,CAAC,QAAQ;4BACxB,MAAM,EAAE,WAAW,CAAC,EAAE;yBACvB;qBACF;oBACD,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE;oBAC7C,MAAM,EAAE;wBACN,QAAQ,EAAE,KAAK,CAAC,QAAQ;wBACxB,MAAM,EAAE,WAAW,CAAC,EAAE;wBACtB,IAAI,EAAE,SAAS;wBACf,MAAM,EAAE,QAAQ;qBACjB;iBACF,CAAC,CAAC;gBAEH,IAAI,OAAO,EAAE,CAAC;oBACZ,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC;wBACpB,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE;wBACtB,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,WAAW,CAAC,EAAE,EAAE,EAAE,EAAE;qBACxD,CAAC,CAAC;gBACL,CAAC;gBAED,OAAO,WAAW,CAAC;YACrB,CAAC,CAAC,CAAC;YAEH,OAAO,CAAC,IAAI,CAAC;gBACX,EAAE,EAAE,IAAI;gBACR,GAAG,EAAE,MAAM;gBACX,MAAM,EAAE,IAAI,CAAC,EAAE;gBACf,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,QAAQ;gBACR,WAAW,EAAE,WAAW,IAAI,IAAI;gBAChC,OAAO,EAAE,OAAO,IAAI,IAAI;gBACxB,iBAAiB;gBACjB,iBAAiB;gBACjB,gBAAgB,EAAE,oBAAoB;aACvC,CAAC,CAAC;YACH,OAAO,IAAI,CAAC,CAAC;QACf,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,CAAC,CAAC;YACZ,OAAO,CAAC,IAAI,CAAC;gBACX,EAAE,EAAE,KAAK;gBACT,GAAG,EAAE,MAAM;gBACX,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe;aAC5D,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC;QAC7B,OAAO;QACP,MAAM;QACN,KAAK,EAAE,IAAI,CAAC,MAAM;QAClB,OAAO;KACR,CAAC;AACJ,CAAC","debug_id":"92478865-5731-54e7-88bf-a4d20ad22e07"}
|
|
@@ -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
|
|
12
|
-
*
|
|
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
|
import { type TranscriptReportVariant } from "../lib/transcriptPdf.js";
|
|
16
15
|
export interface GenerateTranscriptInput {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.d.ts","sourceRoot":"/","sources":["services/transcript.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"transcript.d.ts","sourceRoot":"/","sources":["services/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,EAIL,KAAK,uBAAuB,EAC7B,MAAM,yBAAyB,CAAC;AAUjC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,EAAE,CAAC,EAAE,IAAI,CAAC;IACV;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAuHD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,uBAAuB;;;;;;IAoF5B,yDAAyD;;;;GAK5D;AAED,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IACL,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,EAAE,CAAC,EAAE,IAAI,CAAC;IACV,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;;;;;;;QAfC,yDAAyD;;;;;;mBA6BhC,MAAM;iBAAW,MAAM;;GAwBnD"}
|
|
@@ -8,12 +8,11 @@
|
|
|
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
|
|
12
|
-
*
|
|
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
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
15
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="186437e2-1c14-52c2-94d5-09ccd8035936")}catch(e){}}();
|
|
17
16
|
import { TRPCError } from "@trpc/server";
|
|
18
17
|
import { prisma } from "../lib/prisma.js";
|
|
19
18
|
import { bucket } from "../lib/googleCloudStorage.js";
|
|
@@ -66,29 +65,18 @@ async function fetchStudentAttendance(studentId, from, to) {
|
|
|
66
65
|
...(to ? { lte: to } : {}),
|
|
67
66
|
};
|
|
68
67
|
const hasRange = Object.keys(range).length > 0;
|
|
69
|
-
//
|
|
70
|
-
|
|
68
|
+
// Read AttendanceRecord so EXCUSED records are excluded from the rate
|
|
69
|
+
// denominator (computeAttendanceRate ignores non-PRESENT/LATE/ABSENT).
|
|
70
|
+
const records = await prisma.attendanceRecord.findMany({
|
|
71
71
|
where: {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{ absent: { some: { id: studentId } } },
|
|
76
|
-
],
|
|
77
|
-
...(hasRange ? { date: range } : {}),
|
|
78
|
-
},
|
|
79
|
-
select: {
|
|
80
|
-
id: true,
|
|
81
|
-
present: { where: { id: studentId }, select: { id: true } },
|
|
82
|
-
late: { where: { id: studentId }, select: { id: true } },
|
|
83
|
-
absent: { where: { id: studentId }, select: { id: true } },
|
|
72
|
+
studentId,
|
|
73
|
+
status: { in: ["PRESENT", "LATE", "ABSENT"] },
|
|
74
|
+
...(hasRange ? { attendance: { date: range } } : {}),
|
|
84
75
|
},
|
|
76
|
+
select: { status: true },
|
|
85
77
|
});
|
|
86
|
-
return
|
|
87
|
-
status: record.
|
|
88
|
-
? "PRESENT"
|
|
89
|
-
: record.late.length
|
|
90
|
-
? "LATE"
|
|
91
|
-
: "ABSENT",
|
|
78
|
+
return records.map((record) => ({
|
|
79
|
+
status: record.status,
|
|
92
80
|
}));
|
|
93
81
|
}
|
|
94
82
|
async function buildClassRows(schoolId, studentId) {
|
|
@@ -240,4 +228,4 @@ export async function generateTranscripts(userId, input) {
|
|
|
240
228
|
return { transcripts: results, errors };
|
|
241
229
|
}
|
|
242
230
|
//# sourceMappingURL=transcript.js.map
|
|
243
|
-
//# debugId=
|
|
231
|
+
//# debugId=186437e2-1c14-52c2-94d5-09ccd8035936
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.js","sources":["services/transcript.ts"],"sourceRoot":"/","sourcesContent":["/**\n * School-admin branded transcripts / progress reports (A4 — Nepal pilot).\n *\n * Aggregates per-class gradebook grades + grading boundaries + attendance %\n * into a pdf-lib PDF. Branding: school name + logo (letterhead optional when\n * present on School; schema does not include it yet — do not block).\n *\n * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or\n * a free-text `schoolYear` string for the report header.\n *\n * Attendance is read via the existing present/late/absent M2M lists only\n * (same shaping as getStudentTimeline). Do not conflict with A1 Attendance\n * refactor — rate math lives in attendanceRate.ts.\n */\n\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { bucket } from \"../lib/googleCloudStorage.js\";\nimport {\n buildTranscriptPdf,\n isLevelBasedBoundary,\n type TranscriptClassRow,\n type TranscriptReportVariant,\n} from \"../lib/transcriptPdf.js\";\nimport {\n findActiveSchoolAdminMembership,\n getSchoolById,\n} from \"../models/school.js\";\nimport { computeAttendanceRate } from \"./attendanceRate.js\";\nimport { computeStudentGradeInClass } from \"./gradebook.js\";\n\nconst MAX_BULK = 50;\n\nexport interface GenerateTranscriptInput {\n schoolId: string;\n studentId: string;\n from?: Date;\n to?: Date;\n /**\n * Free-text school year label (e.g. \"2082 BS\", \"2025–2026\").\n * Prefer AcademicTerm (C1) once available.\n */\n schoolYear?: string;\n}\n\nasync function requireSchoolAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can generate transcripts\",\n });\n }\n return membership;\n}\n\nasync function loadFileBytes(\n path: string | null | undefined\n): Promise<Uint8Array | null> {\n if (!path) return null;\n try {\n const [bytes] = await bucket.file(path).download();\n return new Uint8Array(bytes);\n } catch {\n return null;\n }\n}\n\nfunction formatPeriodLabel(from?: Date, to?: Date): string | null {\n if (!from && !to) return null;\n const fmt = (d: Date) => d.toISOString().slice(0, 10);\n if (from && to) return `${fmt(from)} – ${fmt(to)}`;\n if (from) return `From ${fmt(from)}`;\n return `Through ${fmt(to!)}`;\n}\n\nfunction slugify(value: string): string {\n return value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 48);\n}\n\nasync function fetchStudentAttendance(\n studentId: string,\n from?: Date,\n to?: Date\n) {\n const range = {\n ...(from ? { gte: from } : {}),\n ...(to ? { lte: to } : {}),\n };\n const hasRange = Object.keys(range).length > 0;\n\n // Same M2M present/late/absent read path as getStudentTimeline (A1-safe).\n const attendance = await prisma.attendance.findMany({\n where: {\n OR: [\n { present: { some: { id: studentId } } },\n { late: { some: { id: studentId } } },\n { absent: { some: { id: studentId } } },\n ],\n ...(hasRange ? { date: range } : {}),\n },\n select: {\n id: true,\n present: { where: { id: studentId }, select: { id: true } },\n late: { where: { id: studentId }, select: { id: true } },\n absent: { where: { id: studentId }, select: { id: true } },\n },\n });\n\n return attendance.map((record) => ({\n status: record.present.length\n ? (\"PRESENT\" as const)\n : record.late.length\n ? (\"LATE\" as const)\n : (\"ABSENT\" as const),\n }));\n}\n\nasync function buildClassRows(\n schoolId: string,\n studentId: string\n): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {\n const classes = await prisma.class.findMany({\n where: {\n schoolId,\n students: { some: { id: studentId } },\n },\n select: {\n id: true,\n name: true,\n subject: true,\n section: true,\n },\n orderBy: [{ subject: \"asc\" }, { name: \"asc\" }],\n });\n\n const rows: TranscriptClassRow[] = [];\n let levelVotes = 0;\n let letterVotes = 0;\n\n for (const cls of classes) {\n const grade = await computeStudentGradeInClass(cls.id, studentId);\n if (!grade) continue;\n\n if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {\n levelVotes += 1;\n } else if (grade.gradingBoundary) {\n letterVotes += 1;\n }\n\n rows.push({\n className: cls.name,\n subject: cls.subject,\n section: cls.section,\n percentage: grade.result.percentage,\n gradeLabel: grade.letterGrade,\n categorySummaries: grade.result.categories.map((c) => ({\n name: c.name,\n average: c.average,\n })),\n });\n }\n\n const variant: TranscriptReportVariant =\n levelVotes > 0 && levelVotes >= letterVotes ? \"level\" : \"letter\";\n\n return { rows, variant };\n}\n\nexport async function generateTranscript(\n userId: string,\n input: GenerateTranscriptInput\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const school = await getSchoolById(input.schoolId);\n if (!school) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"School not found\" });\n }\n\n const membership = await prisma.schoolMembership.findFirst({\n where: {\n schoolId: input.schoolId,\n userId: input.studentId,\n status: \"ACTIVE\",\n role: \"STUDENT\",\n },\n select: {\n user: {\n select: {\n id: true,\n username: true,\n profile: { select: { displayName: true } },\n },\n },\n },\n });\n if (!membership) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: \"Student is not an active student in this school\",\n });\n }\n\n const studentName =\n membership.user.profile?.displayName || membership.user.username;\n\n const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([\n buildClassRows(input.schoolId, input.studentId),\n fetchStudentAttendance(input.studentId, input.from, input.to),\n loadFileBytes(school.logo?.path),\n ]);\n\n // Soft letterhead support when schema later adds it — never required.\n const schoolAny = school as {\n letterhead?: { path?: string | null; type?: string | null } | null;\n letterheadPath?: string | null;\n letterheadType?: string | null;\n };\n const letterheadPath =\n schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;\n const letterheadType =\n schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;\n const letterheadBytes = await loadFileBytes(letterheadPath);\n\n const attendance = computeAttendanceRate(attendanceStatuses);\n const generatedAt = new Date();\n\n const pdfBytes = await buildTranscriptPdf({\n schoolName: school.name,\n studentName,\n studentUsername: membership.user.username,\n schoolYear: input.schoolYear ?? null,\n periodLabel: formatPeriodLabel(input.from, input.to),\n variant,\n classes: rows,\n attendance,\n logoBytes,\n logoContentType: school.logo?.type ?? null,\n letterheadBytes,\n letterheadContentType: letterheadType,\n generatedAt,\n });\n\n const yearSlug = input.schoolYear\n ? slugify(input.schoolYear)\n : generatedAt.toISOString().slice(0, 10);\n const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;\n\n return {\n pdfBase64: Buffer.from(pdfBytes).toString(\"base64\"),\n fileName,\n variant,\n studentId: input.studentId,\n studentName,\n /** C1 blocker: AcademicTerm model does not exist yet. */\n academicTermSupported: false as const,\n attendance,\n classCount: rows.length,\n };\n}\n\nexport async function generateTranscripts(\n userId: string,\n input: {\n schoolId: string;\n studentIds: string[];\n from?: Date;\n to?: Date;\n schoolYear?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);\n if (uniqueIds.length === 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Select at least one student\",\n });\n }\n\n type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;\n const results: TranscriptResult[] = [];\n const errors: { studentId: string; message: string }[] = [];\n\n for (const studentId of uniqueIds) {\n try {\n const result = await generateTranscript(userId, {\n schoolId: input.schoolId,\n studentId,\n from: input.from,\n to: input.to,\n schoolYear: input.schoolYear,\n });\n results.push(result);\n } catch (error) {\n const message =\n error instanceof TRPCError\n ? error.message\n : error instanceof Error\n ? error.message\n : \"Failed to generate transcript\";\n errors.push({ studentId, message });\n }\n }\n\n return { transcripts: results, errors };\n}\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;;;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,8BAA8B,CAAC;AACtD,OAAO,EACL,kBAAkB,EAClB,oBAAoB,GAGrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,+BAA+B,EAC/B,aAAa,GACd,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AAE5D,MAAM,QAAQ,GAAG,EAAE,CAAC;AAcpB,KAAK,UAAU,kBAAkB,CAAC,QAAgB,EAAE,MAAc;IAChE,MAAM,UAAU,GAAG,MAAM,+BAA+B,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC3E,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,6CAA6C;SACvD,CAAC,CAAC;IACL,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,IAA+B;IAE/B,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACnD,OAAO,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAW,EAAE,EAAS;IAC/C,IAAI,CAAC,IAAI,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,GAAG,GAAG,CAAC,CAAO,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACtD,IAAI,IAAI,IAAI,EAAE;QAAE,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;IACnD,IAAI,IAAI;QAAE,OAAO,QAAQ,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,OAAO,WAAW,GAAG,CAAC,EAAG,CAAC,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,OAAO,CAAC,KAAa;IAC5B,OAAO,KAAK;SACT,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC;SACrB,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,sBAAsB,CACnC,SAAiB,EACjB,IAAW,EACX,EAAS;IAET,MAAM,KAAK,GAAG;QACZ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9B,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;IAE/C,0EAA0E;IAC1E,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;QAClD,KAAK,EAAE;YACL,EAAE,EAAE;gBACF,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE;gBACxC,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE;gBACrC,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE;aACxC;YACD,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACrC;QACD,MAAM,EAAE;YACN,EAAE,EAAE,IAAI;YACR,OAAO,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE;YAC3D,IAAI,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE;YACxD,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE;SAC3D;KACF,CAAC,CAAC;IAEH,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACjC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM;YAC3B,CAAC,CAAE,SAAmB;YACtB,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM;gBAClB,CAAC,CAAE,MAAgB;gBACnB,CAAC,CAAE,QAAkB;KAC1B,CAAC,CAAC,CAAC;AACN,CAAC;AAED,KAAK,UAAU,cAAc,CAC3B,QAAgB,EAChB,SAAiB;IAEjB,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QAC1C,KAAK,EAAE;YACL,QAAQ;YACR,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE;SACtC;QACD,MAAM,EAAE;YACN,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,IAAI;SACd;QACD,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;KAC/C,CAAC,CAAC;IAEH,MAAM,IAAI,GAAyB,EAAE,CAAC;IACtC,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,IAAI,WAAW,GAAG,CAAC,CAAC;IAEpB,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,MAAM,KAAK,GAAG,MAAM,0BAA0B,CAAC,GAAG,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;QAClE,IAAI,CAAC,KAAK;YAAE,SAAS;QAErB,IAAI,oBAAoB,CAAC,KAAK,CAAC,eAAe,EAAE,UAAU,CAAC,EAAE,CAAC;YAC5D,UAAU,IAAI,CAAC,CAAC;QAClB,CAAC;aAAM,IAAI,KAAK,CAAC,eAAe,EAAE,CAAC;YACjC,WAAW,IAAI,CAAC,CAAC;QACnB,CAAC;QAED,IAAI,CAAC,IAAI,CAAC;YACR,SAAS,EAAE,GAAG,CAAC,IAAI;YACnB,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,UAAU;YACnC,UAAU,EAAE,KAAK,CAAC,WAAW;YAC7B,iBAAiB,EAAE,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBACrD,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,OAAO,EAAE,CAAC,CAAC,OAAO;aACnB,CAAC,CAAC;SACJ,CAAC,CAAC;IACL,CAAC;IAED,MAAM,OAAO,GACX,UAAU,GAAG,CAAC,IAAI,UAAU,IAAI,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC;IAEnE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,MAAc,EACd,KAA8B;IAE9B,MAAM,kBAAkB,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAEjD,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC1E,CAAC;IAED,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,gBAAgB,CAAC,SAAS,CAAC;QACzD,KAAK,EAAE;YACL,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,MAAM,EAAE,KAAK,CAAC,SAAS;YACvB,MAAM,EAAE,QAAQ;YAChB,IAAI,EAAE,SAAS;SAChB;QACD,MAAM,EAAE;YACN,IAAI,EAAE;gBACJ,MAAM,EAAE;oBACN,EAAE,EAAE,IAAI;oBACR,QAAQ,EAAE,IAAI;oBACd,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE;iBAC3C;aACF;SACF;KACF,CAAC,CAAC;IACH,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,iDAAiD;SAC3D,CAAC,CAAC;IACL,CAAC;IAED,MAAM,WAAW,GACf,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,IAAI,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC;IAEnE,MAAM,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,kBAAkB,EAAE,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC3E,cAAc,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;QAC/C,sBAAsB,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC;QAC7D,aAAa,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC;KACjC,CAAC,CAAC;IAEH,sEAAsE;IACtE,MAAM,SAAS,GAAG,MAIjB,CAAC;IACF,MAAM,cAAc,GAClB,SAAS,CAAC,UAAU,EAAE,IAAI,IAAI,SAAS,CAAC,cAAc,IAAI,IAAI,CAAC;IACjE,MAAM,cAAc,GAClB,SAAS,CAAC,UAAU,EAAE,IAAI,IAAI,SAAS,CAAC,cAAc,IAAI,IAAI,CAAC;IACjE,MAAM,eAAe,GAAG,MAAM,aAAa,CAAC,cAAc,CAAC,CAAC;IAE5D,MAAM,UAAU,GAAG,qBAAqB,CAAC,kBAAkB,CAAC,CAAC;IAC7D,MAAM,WAAW,GAAG,IAAI,IAAI,EAAE,CAAC;IAE/B,MAAM,QAAQ,GAAG,MAAM,kBAAkB,CAAC;QACxC,UAAU,EAAE,MAAM,CAAC,IAAI;QACvB,WAAW;QACX,eAAe,EAAE,UAAU,CAAC,IAAI,CAAC,QAAQ;QACzC,UAAU,EAAE,KAAK,CAAC,UAAU,IAAI,IAAI;QACpC,WAAW,EAAE,iBAAiB,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC;QACpD,OAAO;QACP,OAAO,EAAE,IAAI;QACb,UAAU;QACV,SAAS;QACT,eAAe,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,IAAI,IAAI;QAC1C,eAAe;QACf,qBAAqB,EAAE,cAAc;QACrC,WAAW;KACZ,CAAC,CAAC;IAEH,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU;QAC/B,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC;QAC3B,CAAC,CAAC,WAAW,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,mBAAmB,OAAO,CAAC,WAAW,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,QAAQ,IAAI,QAAQ,MAAM,CAAC;IAEvG,OAAO;QACL,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACnD,QAAQ;QACR,OAAO;QACP,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,WAAW;QACX,yDAAyD;QACzD,qBAAqB,EAAE,KAAc;QACrC,UAAU;QACV,UAAU,EAAE,IAAI,CAAC,MAAM;KACxB,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,MAAc,EACd,KAMC;IAED,MAAM,kBAAkB,CAAC,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAEjD,MAAM,SAAS,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IACpE,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,aAAa;YACnB,OAAO,EAAE,6BAA6B;SACvC,CAAC,CAAC;IACL,CAAC;IAGD,MAAM,OAAO,GAAuB,EAAE,CAAC;IACvC,MAAM,MAAM,GAA6C,EAAE,CAAC;IAE5D,KAAK,MAAM,SAAS,IAAI,SAAS,EAAE,CAAC;QAClC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,kBAAkB,CAAC,MAAM,EAAE;gBAC9C,QAAQ,EAAE,KAAK,CAAC,QAAQ;gBACxB,SAAS;gBACT,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,UAAU,EAAE,KAAK,CAAC,UAAU;aAC7B,CAAC,CAAC;YACH,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACvB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,OAAO,GACX,KAAK,YAAY,SAAS;gBACxB,CAAC,CAAC,KAAK,CAAC,OAAO;gBACf,CAAC,CAAC,KAAK,YAAY,KAAK;oBACtB,CAAC,CAAC,KAAK,CAAC,OAAO;oBACf,CAAC,CAAC,+BAA+B,CAAC;YACxC,MAAM,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAED,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AAC1C,CAAC","debug_id":"c7eb65fe-e18f-5b5b-9897-ce16dc61c93d"}
|
|
1
|
+
{"version":3,"file":"transcript.js","sources":["services/transcript.ts"],"sourceRoot":"/","sourcesContent":["/**\n * School-admin branded transcripts / progress reports (A4 — Nepal pilot).\n *\n * Aggregates per-class gradebook grades + grading boundaries + attendance %\n * into a pdf-lib PDF. Branding: school name + logo (letterhead optional when\n * present on School; schema does not include it yet — do not block).\n *\n * AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or\n * a free-text `schoolYear` string for the report header.\n *\n * Attendance is read from AttendanceRecord (EXCUSED excluded from the rate\n * denominator) — rate math lives in attendanceRate.ts.\n */\n\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { bucket } from \"../lib/googleCloudStorage.js\";\nimport {\n buildTranscriptPdf,\n isLevelBasedBoundary,\n type TranscriptClassRow,\n type TranscriptReportVariant,\n} from \"../lib/transcriptPdf.js\";\nimport {\n findActiveSchoolAdminMembership,\n getSchoolById,\n} from \"../models/school.js\";\nimport { computeAttendanceRate } from \"./attendanceRate.js\";\nimport { computeStudentGradeInClass } from \"./gradebook.js\";\n\nconst MAX_BULK = 50;\n\nexport interface GenerateTranscriptInput {\n schoolId: string;\n studentId: string;\n from?: Date;\n to?: Date;\n /**\n * Free-text school year label (e.g. \"2082 BS\", \"2025–2026\").\n * Prefer AcademicTerm (C1) once available.\n */\n schoolYear?: string;\n}\n\nasync function requireSchoolAdmin(schoolId: string, userId: string) {\n const membership = await findActiveSchoolAdminMembership(schoolId, userId);\n if (!membership) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message: \"Only school admins can generate transcripts\",\n });\n }\n return membership;\n}\n\nasync function loadFileBytes(\n path: string | null | undefined\n): Promise<Uint8Array | null> {\n if (!path) return null;\n try {\n const [bytes] = await bucket.file(path).download();\n return new Uint8Array(bytes);\n } catch {\n return null;\n }\n}\n\nfunction formatPeriodLabel(from?: Date, to?: Date): string | null {\n if (!from && !to) return null;\n const fmt = (d: Date) => d.toISOString().slice(0, 10);\n if (from && to) return `${fmt(from)} – ${fmt(to)}`;\n if (from) return `From ${fmt(from)}`;\n return `Through ${fmt(to!)}`;\n}\n\nfunction slugify(value: string): string {\n return value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 48);\n}\n\nasync function fetchStudentAttendance(\n studentId: string,\n from?: Date,\n to?: Date\n) {\n const range = {\n ...(from ? { gte: from } : {}),\n ...(to ? { lte: to } : {}),\n };\n const hasRange = Object.keys(range).length > 0;\n\n // Read AttendanceRecord so EXCUSED records are excluded from the rate\n // denominator (computeAttendanceRate ignores non-PRESENT/LATE/ABSENT).\n const records = await prisma.attendanceRecord.findMany({\n where: {\n studentId,\n status: { in: [\"PRESENT\", \"LATE\", \"ABSENT\"] },\n ...(hasRange ? { attendance: { date: range } } : {}),\n },\n select: { status: true },\n });\n\n return records.map((record) => ({\n status: record.status as \"PRESENT\" | \"LATE\" | \"ABSENT\",\n }));\n}\n\nasync function buildClassRows(\n schoolId: string,\n studentId: string\n): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {\n const classes = await prisma.class.findMany({\n where: {\n schoolId,\n students: { some: { id: studentId } },\n },\n select: {\n id: true,\n name: true,\n subject: true,\n section: true,\n },\n orderBy: [{ subject: \"asc\" }, { name: \"asc\" }],\n });\n\n const rows: TranscriptClassRow[] = [];\n let levelVotes = 0;\n let letterVotes = 0;\n\n for (const cls of classes) {\n const grade = await computeStudentGradeInClass(cls.id, studentId);\n if (!grade) continue;\n\n if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {\n levelVotes += 1;\n } else if (grade.gradingBoundary) {\n letterVotes += 1;\n }\n\n rows.push({\n className: cls.name,\n subject: cls.subject,\n section: cls.section,\n percentage: grade.result.percentage,\n gradeLabel: grade.letterGrade,\n categorySummaries: grade.result.categories.map((c) => ({\n name: c.name,\n average: c.average,\n })),\n });\n }\n\n const variant: TranscriptReportVariant =\n levelVotes > 0 && levelVotes >= letterVotes ? \"level\" : \"letter\";\n\n return { rows, variant };\n}\n\nexport async function generateTranscript(\n userId: string,\n input: GenerateTranscriptInput\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const school = await getSchoolById(input.schoolId);\n if (!school) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"School not found\" });\n }\n\n const membership = await prisma.schoolMembership.findFirst({\n where: {\n schoolId: input.schoolId,\n userId: input.studentId,\n status: \"ACTIVE\",\n role: \"STUDENT\",\n },\n select: {\n user: {\n select: {\n id: true,\n username: true,\n profile: { select: { displayName: true } },\n },\n },\n },\n });\n if (!membership) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: \"Student is not an active student in this school\",\n });\n }\n\n const studentName =\n membership.user.profile?.displayName || membership.user.username;\n\n const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([\n buildClassRows(input.schoolId, input.studentId),\n fetchStudentAttendance(input.studentId, input.from, input.to),\n loadFileBytes(school.logo?.path),\n ]);\n\n // Soft letterhead support when schema later adds it — never required.\n const schoolAny = school as {\n letterhead?: { path?: string | null; type?: string | null } | null;\n letterheadPath?: string | null;\n letterheadType?: string | null;\n };\n const letterheadPath =\n schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;\n const letterheadType =\n schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;\n const letterheadBytes = await loadFileBytes(letterheadPath);\n\n const attendance = computeAttendanceRate(attendanceStatuses);\n const generatedAt = new Date();\n\n const pdfBytes = await buildTranscriptPdf({\n schoolName: school.name,\n studentName,\n studentUsername: membership.user.username,\n schoolYear: input.schoolYear ?? null,\n periodLabel: formatPeriodLabel(input.from, input.to),\n variant,\n classes: rows,\n attendance,\n logoBytes,\n logoContentType: school.logo?.type ?? null,\n letterheadBytes,\n letterheadContentType: letterheadType,\n generatedAt,\n });\n\n const yearSlug = input.schoolYear\n ? slugify(input.schoolYear)\n : generatedAt.toISOString().slice(0, 10);\n const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;\n\n return {\n pdfBase64: Buffer.from(pdfBytes).toString(\"base64\"),\n fileName,\n variant,\n studentId: input.studentId,\n studentName,\n /** C1 blocker: AcademicTerm model does not exist yet. */\n academicTermSupported: false as const,\n attendance,\n classCount: rows.length,\n };\n}\n\nexport async function generateTranscripts(\n userId: string,\n input: {\n schoolId: string;\n studentIds: string[];\n from?: Date;\n to?: Date;\n schoolYear?: string;\n }\n) {\n await requireSchoolAdmin(input.schoolId, userId);\n\n const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);\n if (uniqueIds.length === 0) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Select at least one student\",\n });\n }\n\n type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;\n const results: TranscriptResult[] = [];\n const errors: { studentId: string; message: string }[] = [];\n\n for (const studentId of uniqueIds) {\n try {\n const result = await generateTranscript(userId, {\n schoolId: input.schoolId,\n studentId,\n from: input.from,\n to: input.to,\n schoolYear: input.schoolYear,\n });\n results.push(result);\n } catch (error) {\n const message =\n error instanceof TRPCError\n ? error.message\n : error instanceof Error\n ? error.message\n : \"Failed to generate transcript\";\n errors.push({ studentId, message });\n }\n }\n\n return { transcripts: results, errors };\n}\n"],"names":[],"mappings":"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","debug_id":"186437e2-1c14-52c2-94d5-09ccd8035936"}
|
package/package.json
CHANGED
|
@@ -93,6 +93,12 @@ function parseArgs(argv: string[]) {
|
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function parseBool(value: string | boolean | undefined): boolean {
|
|
97
|
+
if (value === undefined) return false;
|
|
98
|
+
if (typeof value === "boolean") return value;
|
|
99
|
+
return !["false", "0", "no"].includes(value.trim().toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
function loadJson<T>(path: string): T {
|
|
97
103
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
98
104
|
}
|
|
@@ -174,13 +180,19 @@ async function main() {
|
|
|
174
180
|
process.exit(1);
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
const dryRun =
|
|
178
|
-
const force =
|
|
179
|
-
const skipBoundary =
|
|
180
|
-
const
|
|
183
|
+
const dryRun = parseBool(args["dry-run"]);
|
|
184
|
+
const force = parseBool(args.force);
|
|
185
|
+
const skipBoundary = parseBool(args["skip-boundary"]);
|
|
186
|
+
const rawGrades = String(args.grades ?? "4,5")
|
|
181
187
|
.split(",")
|
|
182
|
-
.map((g) => Number(g.trim()))
|
|
183
|
-
|
|
188
|
+
.map((g) => Number(g.trim()));
|
|
189
|
+
const grades = rawGrades.filter((g): g is 4 | 5 => g === 4 || g === 5);
|
|
190
|
+
if (grades.length === 0 || grades.length !== rawGrades.length) {
|
|
191
|
+
console.error(
|
|
192
|
+
`Invalid --grades value: ${String(args.grades)}. Use 4, 5, or 4,5.`
|
|
193
|
+
);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
184
196
|
const subjectFilter = args.subjects
|
|
185
197
|
? String(args.subjects)
|
|
186
198
|
.split(",")
|
|
@@ -219,6 +231,17 @@ async function main() {
|
|
|
219
231
|
loadJson<SubjectFile>(join(PACK_DIR, "subjects", f))
|
|
220
232
|
);
|
|
221
233
|
|
|
234
|
+
if (subjectFilter) {
|
|
235
|
+
const knownKeys = new Set(subjects.map((s) => s.key));
|
|
236
|
+
const unknown = subjectFilter.filter((key) => !knownKeys.has(key));
|
|
237
|
+
if (unknown.length > 0) {
|
|
238
|
+
console.error(
|
|
239
|
+
`Unknown --subjects value(s): ${unknown.join(", ")}. Known: ${[...knownKeys].join(", ")}`
|
|
240
|
+
);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
const units = subjects
|
|
223
246
|
.filter((s) => !subjectFilter || subjectFilter.includes(s.key))
|
|
224
247
|
.flatMap((s) => s.units)
|
package/src/models/attendance.ts
CHANGED
|
@@ -370,18 +370,16 @@ function buildRecordsFromLists(input: {
|
|
|
370
370
|
status: AttendanceStatus;
|
|
371
371
|
updatedById?: string | null;
|
|
372
372
|
}> = [];
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
records.push({ studentId, status
|
|
378
|
-
}
|
|
379
|
-
for (const studentId of input.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (const studentId of input.excusedIds)
|
|
383
|
-
records.push({ studentId, status: "EXCUSED", updatedById: input.updatedById });
|
|
384
|
-
}
|
|
373
|
+
const seen = new Set<string>();
|
|
374
|
+
const pushUnique = (studentId: string, status: AttendanceStatus) => {
|
|
375
|
+
if (seen.has(studentId)) return;
|
|
376
|
+
seen.add(studentId);
|
|
377
|
+
records.push({ studentId, status, updatedById: input.updatedById });
|
|
378
|
+
};
|
|
379
|
+
for (const studentId of input.presentIds) pushUnique(studentId, "PRESENT");
|
|
380
|
+
for (const studentId of input.lateIds) pushUnique(studentId, "LATE");
|
|
381
|
+
for (const studentId of input.absentIds) pushUnique(studentId, "ABSENT");
|
|
382
|
+
for (const studentId of input.excusedIds) pushUnique(studentId, "EXCUSED");
|
|
385
383
|
return records;
|
|
386
384
|
}
|
|
387
385
|
|
|
@@ -71,10 +71,13 @@ export async function searchMarketplaceListings(input: {
|
|
|
71
71
|
limit: number;
|
|
72
72
|
/** When true, include the caller's own hidden listings (for authors). */
|
|
73
73
|
authorId?: string;
|
|
74
|
+
/** When set, restrict the entire result set (including PUBLIC listings) to this school. */
|
|
75
|
+
scopeSchoolId?: string;
|
|
74
76
|
}) {
|
|
75
77
|
const search = input.search?.trim();
|
|
76
78
|
const where: Prisma.MarketplaceListingWhereInput = {
|
|
77
79
|
AND: [
|
|
80
|
+
...(input.scopeSchoolId ? [{ schoolId: input.scopeSchoolId }] : []),
|
|
78
81
|
{
|
|
79
82
|
OR: [
|
|
80
83
|
{ visibility: "PUBLIC", isHidden: false },
|
package/src/models/moduleItem.ts
CHANGED
|
@@ -96,12 +96,30 @@ export function deleteModuleItem(id: string) {
|
|
|
96
96
|
return prisma.moduleItem.delete({ where: { id } });
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function fileAccessibleToUserWhere(userId: string) {
|
|
100
|
+
return {
|
|
101
|
+
OR: [
|
|
102
|
+
{ userId },
|
|
103
|
+
{ folder: { personalOwnerId: userId } },
|
|
104
|
+
{ folder: { class: { is: classTeacherOrSchoolAdminWhere(userId) } } },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function findAccessibleFilesByIds(fileIds: string[], userId: string) {
|
|
110
|
+
return prisma.file.findMany({
|
|
111
|
+
where: { id: { in: fileIds }, ...fileAccessibleToUserWhere(userId) },
|
|
112
|
+
select: { id: true, type: true },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
export function attachFilesToModuleItem(
|
|
100
117
|
moduleItemId: string,
|
|
101
|
-
fileIds: string[]
|
|
118
|
+
fileIds: string[],
|
|
119
|
+
userId: string
|
|
102
120
|
) {
|
|
103
121
|
return prisma.file.updateMany({
|
|
104
|
-
where: { id: { in: fileIds } },
|
|
122
|
+
where: { id: { in: fileIds }, ...fileAccessibleToUserWhere(userId) },
|
|
105
123
|
data: { moduleItemId },
|
|
106
124
|
});
|
|
107
125
|
}
|
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
updateModuleItemRecord,
|
|
15
15
|
} from "../services/moduleItem.js";
|
|
16
16
|
|
|
17
|
+
const MAX_MODULE_FILE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB (videos)
|
|
18
|
+
|
|
17
19
|
const directFileSchema = z.object({
|
|
18
|
-
name: z.string(),
|
|
19
|
-
type: z.string(),
|
|
20
|
-
size: z.number(),
|
|
20
|
+
name: z.string().trim().min(1).max(255),
|
|
21
|
+
type: z.string().trim().min(1).max(255),
|
|
22
|
+
size: z.number().int().positive().max(MAX_MODULE_FILE_BYTES),
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
const moduleItemTypeSchema = z.enum(["PAGE", "FILES", "VIDEO"]);
|
|
@@ -76,7 +78,7 @@ export const moduleItemRouter = createTRPCRouter({
|
|
|
76
78
|
z.object({
|
|
77
79
|
classId: z.string(),
|
|
78
80
|
sectionId: z.string(),
|
|
79
|
-
orderedIds: z.array(z.string()),
|
|
81
|
+
orderedIds: z.array(z.string()).min(1).max(500),
|
|
80
82
|
})
|
|
81
83
|
)
|
|
82
84
|
.mutation(({ ctx, input }) => reorderModuleItems(ctx.user!.id, input)),
|
package/src/routers/school.ts
CHANGED
|
@@ -325,9 +325,14 @@ export const schoolRouter = createTRPCRouter({
|
|
|
325
325
|
*/
|
|
326
326
|
generateTranscript: protectedProcedure
|
|
327
327
|
.input(
|
|
328
|
-
transcriptPeriodSchema
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
transcriptPeriodSchema
|
|
329
|
+
.extend({
|
|
330
|
+
studentId: z.string(),
|
|
331
|
+
})
|
|
332
|
+
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
333
|
+
message: "from must be on or before to",
|
|
334
|
+
path: ["from"],
|
|
335
|
+
})
|
|
331
336
|
)
|
|
332
337
|
.mutation(({ ctx, input }) => generateTranscript(ctx.user!.id, input)),
|
|
333
338
|
|
|
@@ -337,9 +342,14 @@ export const schoolRouter = createTRPCRouter({
|
|
|
337
342
|
*/
|
|
338
343
|
generateTranscripts: protectedProcedure
|
|
339
344
|
.input(
|
|
340
|
-
transcriptPeriodSchema
|
|
341
|
-
|
|
342
|
-
|
|
345
|
+
transcriptPeriodSchema
|
|
346
|
+
.extend({
|
|
347
|
+
studentIds: z.array(z.string()).min(1).max(50),
|
|
348
|
+
})
|
|
349
|
+
.refine((v) => !v.from || !v.to || v.from <= v.to, {
|
|
350
|
+
message: "from must be on or before to",
|
|
351
|
+
path: ["from"],
|
|
352
|
+
})
|
|
343
353
|
)
|
|
344
354
|
.mutation(({ ctx, input }) => generateTranscripts(ctx.user!.id, input)),
|
|
345
355
|
|
|
@@ -89,6 +89,20 @@ export async function updateYear(
|
|
|
89
89
|
}
|
|
90
90
|
) {
|
|
91
91
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
92
|
+
if (input.startDate !== undefined || input.endDate !== undefined) {
|
|
93
|
+
const existing = await getAcademicYearById(input.yearId, input.schoolId);
|
|
94
|
+
if (!existing) {
|
|
95
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
|
|
96
|
+
}
|
|
97
|
+
const nextStart = input.startDate ?? existing.startDate;
|
|
98
|
+
const nextEnd = input.endDate ?? existing.endDate;
|
|
99
|
+
if (nextEnd < nextStart) {
|
|
100
|
+
throw new TRPCError({
|
|
101
|
+
code: "BAD_REQUEST",
|
|
102
|
+
message: "endDate must be on or after startDate",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
92
106
|
const year = await updateAcademicYear(input.yearId, input.schoolId, {
|
|
93
107
|
name: input.name,
|
|
94
108
|
startDate: input.startDate,
|
|
@@ -177,6 +191,20 @@ export async function updateTerm(
|
|
|
177
191
|
if (!year) {
|
|
178
192
|
throw new TRPCError({ code: "NOT_FOUND", message: "Academic year not found" });
|
|
179
193
|
}
|
|
194
|
+
if (input.startDate !== undefined || input.endDate !== undefined) {
|
|
195
|
+
const existingTerm = year.terms.find((t) => t.id === input.termId);
|
|
196
|
+
if (!existingTerm) {
|
|
197
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Academic term not found" });
|
|
198
|
+
}
|
|
199
|
+
const nextStart = input.startDate ?? existingTerm.startDate;
|
|
200
|
+
const nextEnd = input.endDate ?? existingTerm.endDate;
|
|
201
|
+
if (nextEnd < nextStart) {
|
|
202
|
+
throw new TRPCError({
|
|
203
|
+
code: "BAD_REQUEST",
|
|
204
|
+
message: "endDate must be on or after startDate",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
180
208
|
const term = await updateAcademicTerm(input.termId, input.yearId, {
|
|
181
209
|
name: input.name,
|
|
182
210
|
startDate: input.startDate,
|
|
@@ -291,7 +291,11 @@ export async function updateAttendanceRecord(
|
|
|
291
291
|
return shapeAttendanceSession(updated);
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* School-admin: today's attendance rollup across classes.
|
|
296
|
+
* `percentage` is session-weighted (share of attended class-session records),
|
|
297
|
+
* not a share of `totalStudents`; a student in six classes contributes six records.
|
|
298
|
+
*/
|
|
295
299
|
export async function listTodayAttendanceSummary(
|
|
296
300
|
userId: string,
|
|
297
301
|
schoolId: string
|
|
@@ -440,13 +444,13 @@ export async function listExcusals(
|
|
|
440
444
|
const now = toDateOnly(new Date());
|
|
441
445
|
|
|
442
446
|
return excusals.map((excusal) => {
|
|
443
|
-
let status: "ACTIVE" | "UPCOMING" | "REVOKED" = "ACTIVE";
|
|
447
|
+
let status: "ACTIVE" | "UPCOMING" | "EXPIRED" | "REVOKED" = "ACTIVE";
|
|
444
448
|
if (excusal.revokedAt) {
|
|
445
449
|
status = "REVOKED";
|
|
446
450
|
} else if (toDateOnly(excusal.startDate) > now) {
|
|
447
451
|
status = "UPCOMING";
|
|
448
452
|
} else if (toDateOnly(excusal.endDate) < now) {
|
|
449
|
-
status = "
|
|
453
|
+
status = "EXPIRED";
|
|
450
454
|
}
|
|
451
455
|
|
|
452
456
|
return {
|
package/src/services/class.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Class service – CRUD for classes, members, grades, mark schemes, grading boundaries,
|
|
3
3
|
* syllabus, lab drafts, and invite codes.
|
|
4
4
|
*/
|
|
5
|
-
import { SessionType } from "@prisma/client";
|
|
5
|
+
import { Prisma, SessionType } from "@prisma/client";
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { generateInviteCode } from "../utils/generateInviteCode.js";
|
|
@@ -247,7 +247,7 @@ export async function updateClass(
|
|
|
247
247
|
studentsCanReply: data.studentsCanReply,
|
|
248
248
|
}),
|
|
249
249
|
...(data.sidebarFeatures !== undefined && {
|
|
250
|
-
sidebarFeatures: data.sidebarFeatures,
|
|
250
|
+
sidebarFeatures: data.sidebarFeatures ?? Prisma.DbNull,
|
|
251
251
|
}),
|
|
252
252
|
} as Parameters<typeof prisma.class.update>[0]["data"],
|
|
253
253
|
select: {
|