@studious-lms/server 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +0 -1
- package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
- package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
- package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
- package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
- package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
- package/dist/lib/assignmentTypeDefaults.js +167 -0
- package/dist/lib/assignmentTypeDefaults.js.map +1 -0
- package/dist/lib/config/env.d.ts +0 -8
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +2 -5
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/feePdf.d.ts +19 -0
- package/dist/lib/feePdf.d.ts.map +1 -0
- package/dist/lib/feePdf.js +167 -0
- package/dist/lib/feePdf.js.map +1 -0
- package/dist/lib/feePdfFormat.d.ts +4 -0
- package/dist/lib/feePdfFormat.d.ts.map +1 -0
- package/dist/lib/feePdfFormat.js +16 -0
- package/dist/lib/feePdfFormat.js.map +1 -0
- package/dist/lib/idCardPdf.d.ts +26 -0
- package/dist/lib/idCardPdf.d.ts.map +1 -0
- package/dist/lib/idCardPdf.js +272 -0
- package/dist/lib/idCardPdf.js.map +1 -0
- package/dist/lib/pdfLetterhead.d.ts +41 -0
- package/dist/lib/pdfLetterhead.d.ts.map +1 -0
- package/dist/lib/pdfLetterhead.js +86 -0
- package/dist/lib/pdfLetterhead.js.map +1 -0
- package/dist/lib/transcriptPdf.d.ts +55 -0
- package/dist/lib/transcriptPdf.d.ts.map +1 -0
- package/dist/lib/transcriptPdf.js +303 -0
- package/dist/lib/transcriptPdf.js.map +1 -0
- package/dist/models/academicCalendar.d.ts +198 -0
- package/dist/models/academicCalendar.d.ts.map +1 -0
- package/dist/models/academicCalendar.js +106 -0
- package/dist/models/academicCalendar.js.map +1 -0
- package/dist/models/agenda.d.ts +8 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +66 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +659 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +544 -75
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/auth.d.ts +2 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +12 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +3 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/event.d.ts +16 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/fees.d.ts +554 -0
- package/dist/models/fees.d.ts.map +1 -0
- package/dist/models/fees.js +278 -0
- package/dist/models/fees.js.map +1 -0
- package/dist/models/file.d.ts +29 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +2 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +2 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketplace.d.ts +178 -0
- package/dist/models/marketplace.d.ts.map +1 -0
- package/dist/models/marketplace.js +115 -0
- package/dist/models/marketplace.js.map +1 -0
- package/dist/models/moduleItem.d.ts +304 -0
- package/dist/models/moduleItem.d.ts.map +1 -0
- package/dist/models/moduleItem.js +78 -0
- package/dist/models/moduleItem.js.map +1 -0
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +18 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +7 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +4 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +19 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +11341 -7097
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +12 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/academicCalendar.d.ts +229 -0
- package/dist/routers/academicCalendar.d.ts.map +1 -0
- package/dist/routers/academicCalendar.js +75 -0
- package/dist/routers/academicCalendar.js.map +1 -0
- package/dist/routers/admin.d.ts +2 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +8 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +75 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +10 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +103 -0
- package/dist/routers/attendance.d.ts.map +1 -1
- package/dist/routers/attendance.js +8 -5
- package/dist/routers/attendance.js.map +1 -1
- package/dist/routers/class.d.ts +27 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +6 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/event.d.ts +8 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/fees.d.ts +513 -0
- package/dist/routers/fees.d.ts.map +1 -0
- package/dist/routers/fees.js +88 -0
- package/dist/routers/fees.js.map +1 -0
- package/dist/routers/file.d.ts +2 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/gradebook.d.ts +4 -1
- package/dist/routers/gradebook.d.ts.map +1 -1
- package/dist/routers/marketplace.d.ts +371 -0
- package/dist/routers/marketplace.d.ts.map +1 -0
- package/dist/routers/marketplace.js +87 -0
- package/dist/routers/marketplace.js.map +1 -0
- package/dist/routers/moduleItem.d.ts +263 -0
- package/dist/routers/moduleItem.d.ts.map +1 -0
- package/dist/routers/moduleItem.js +60 -0
- package/dist/routers/moduleItem.js.map +1 -0
- package/dist/routers/parent.d.ts +169 -0
- package/dist/routers/parent.d.ts.map +1 -0
- package/dist/routers/parent.js +49 -0
- package/dist/routers/parent.js.map +1 -0
- package/dist/routers/school.d.ts +363 -7
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +100 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/worksheet.d.ts +27 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +10 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/academicCalendar.d.ts +173 -0
- package/dist/services/academicCalendar.d.ts.map +1 -0
- package/dist/services/academicCalendar.js +214 -0
- package/dist/services/academicCalendar.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/agenda.d.ts +8 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +75 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +91 -12
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +323 -10
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +385 -25
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/attendanceMerge.d.ts +53 -0
- package/dist/services/attendanceMerge.d.ts.map +1 -0
- package/dist/services/attendanceMerge.js +87 -0
- package/dist/services/attendanceMerge.js.map +1 -0
- package/dist/services/attendanceRate.d.ts +24 -0
- package/dist/services/attendanceRate.d.ts.map +1 -0
- package/dist/services/attendanceRate.js +31 -0
- package/dist/services/attendanceRate.js.map +1 -0
- package/dist/services/attendanceStats.d.ts +22 -0
- package/dist/services/attendanceStats.d.ts.map +1 -0
- package/dist/services/attendanceStats.js +45 -0
- package/dist/services/attendanceStats.js.map +1 -0
- package/dist/services/class.d.ts +27 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +26 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +8 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/fees.d.ts +422 -0
- package/dist/services/fees.d.ts.map +1 -0
- package/dist/services/fees.js +310 -0
- package/dist/services/fees.js.map +1 -0
- package/dist/services/feesAuthz.d.ts +49 -0
- package/dist/services/feesAuthz.d.ts.map +1 -0
- package/dist/services/feesAuthz.js +52 -0
- package/dist/services/feesAuthz.js.map +1 -0
- package/dist/services/file.d.ts +4 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/gradebook.d.ts +18 -1
- package/dist/services/gradebook.d.ts.map +1 -1
- package/dist/services/gradebook.js +57 -2
- package/dist/services/gradebook.js.map +1 -1
- package/dist/services/idCard.d.ts +11 -0
- package/dist/services/idCard.d.ts.map +1 -0
- package/dist/services/idCard.js +122 -0
- package/dist/services/idCard.js.map +1 -0
- package/dist/services/marketplace.d.ts +301 -0
- package/dist/services/marketplace.d.ts.map +1 -0
- package/dist/services/marketplace.js +628 -0
- package/dist/services/marketplace.js.map +1 -0
- package/dist/services/moduleItem.d.ts +196 -0
- package/dist/services/moduleItem.d.ts.map +1 -0
- package/dist/services/moduleItem.js +188 -0
- package/dist/services/moduleItem.js.map +1 -0
- package/dist/services/notification.d.ts +58 -0
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +30 -2
- package/dist/services/notification.js.map +1 -1
- package/dist/services/parentPortal.d.ts +133 -0
- package/dist/services/parentPortal.d.ts.map +1 -0
- package/dist/services/parentPortal.js +328 -0
- package/dist/services/parentPortal.js.map +1 -0
- package/dist/services/school.d.ts +28 -3
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +65 -45
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts +34 -0
- package/dist/services/studentImport.d.ts.map +1 -0
- package/dist/services/studentImport.js +236 -0
- package/dist/services/studentImport.js.map +1 -0
- package/dist/services/transcript.d.ts +61 -0
- package/dist/services/transcript.d.ts.map +1 -0
- package/dist/services/transcript.js +243 -0
- package/dist/services/transcript.js.map +1 -0
- package/dist/services/worksheet.d.ts +22 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +94 -2
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +5 -5
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +4 -5
- package/dist/utils/adminPanelAccess.d.ts.map +1 -1
- package/dist/utils/adminPanelAccess.js +12 -34
- package/dist/utils/adminPanelAccess.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
- package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
- package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
- package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
- package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
- package/prisma/schema.prisma +371 -11
- package/scripts/import-nepal-cdc-curriculum.ts +379 -0
- package/src/lib/assignmentTypeDefaults.ts +222 -0
- package/src/lib/config/env.ts +0 -3
- package/src/lib/feePdf.ts +199 -0
- package/src/lib/feePdfFormat.ts +14 -0
- package/src/lib/idCardPdf.ts +336 -0
- package/src/lib/pdfLetterhead.ts +124 -0
- package/src/lib/transcriptPdf.ts +502 -0
- package/src/models/academicCalendar.ts +139 -0
- package/src/models/attendance.ts +704 -73
- package/src/models/class.ts +1 -0
- package/src/models/fees.ts +349 -0
- package/src/models/marketplace.ts +158 -0
- package/src/models/moduleItem.ts +107 -0
- package/src/models/school.ts +6 -0
- package/src/models/worksheet.ts +2 -0
- package/src/routers/_app.ts +10 -0
- package/src/routers/academicCalendar.ts +103 -0
- package/src/routers/assignment.ts +8 -0
- package/src/routers/attendance.ts +7 -3
- package/src/routers/class.ts +4 -0
- package/src/routers/fees.ts +126 -0
- package/src/routers/marketplace.ts +134 -0
- package/src/routers/moduleItem.ts +89 -0
- package/src/routers/parent.ts +68 -0
- package/src/routers/school.ts +139 -1
- package/src/routers/worksheet.ts +11 -0
- package/src/services/academicCalendar.ts +299 -0
- package/src/services/assignment.ts +122 -12
- package/src/services/attendance.ts +548 -26
- package/src/services/attendanceMerge.ts +139 -0
- package/src/services/attendanceRate.ts +41 -0
- package/src/services/attendanceStats.ts +63 -0
- package/src/services/class.ts +25 -1
- package/src/services/fees.ts +424 -0
- package/src/services/feesAuthz.ts +77 -0
- package/src/services/gradebook.ts +70 -0
- package/src/services/idCard.ts +147 -0
- package/src/services/marketplace.ts +785 -0
- package/src/services/moduleItem.ts +274 -0
- package/src/services/notification.ts +43 -0
- package/src/services/parentPortal.ts +380 -0
- package/src/services/school.ts +65 -44
- package/src/services/studentImport.ts +273 -0
- package/src/services/transcript.ts +313 -0
- package/src/services/worksheet.ts +111 -0
- package/src/trpc.ts +3 -3
- package/src/utils/adminPanelAccess.ts +12 -35
- package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
- package/tests/unit/lib/feePdf.test.ts +15 -0
- package/tests/unit/lib/idCardPdf.test.ts +40 -0
- package/tests/unit/lib/transcriptPdf.test.ts +111 -0
- package/tests/unit/services/attendanceMerge.test.ts +90 -0
- package/tests/unit/services/attendanceRate.test.ts +38 -0
- package/tests/unit/services/attendanceStats.test.ts +54 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C3: Bulk student CSV onboarding under school admin.
|
|
3
|
+
*
|
|
4
|
+
* CSV columns (header row required):
|
|
5
|
+
* displayName, username?, email?, password?, classId?
|
|
6
|
+
*
|
|
7
|
+
* Username/password are generated when omitted. Email is optional — a
|
|
8
|
+
* placeholder local-part is used so the User.email uniqueness constraint holds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
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]="ce1c4284-8c8b-5ddd-bed4-ff674956801e")}catch(e){}}();
|
|
12
|
+
import { randomBytes } from "crypto";
|
|
13
|
+
import { hash } from "bcryptjs";
|
|
14
|
+
import { TRPCError } from "@trpc/server";
|
|
15
|
+
import { prisma } from "../lib/prisma.js";
|
|
16
|
+
import { createUser, findUserByEmail, findUserByUsername } from "../models/auth.js";
|
|
17
|
+
import { findActiveSchoolAdminMembership, upsertSchoolMembership } from "../models/school.js";
|
|
18
|
+
import { upsertUserProfile } from "../models/user.js";
|
|
19
|
+
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
20
|
+
function parseCsvLine(line) {
|
|
21
|
+
const cells = [];
|
|
22
|
+
let current = "";
|
|
23
|
+
let inQuotes = false;
|
|
24
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
25
|
+
const ch = line[i];
|
|
26
|
+
if (inQuotes) {
|
|
27
|
+
if (ch === '"') {
|
|
28
|
+
if (line[i + 1] === '"') {
|
|
29
|
+
current += '"';
|
|
30
|
+
i += 1;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
inQuotes = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
current += ch;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (ch === '"') {
|
|
41
|
+
inQuotes = true;
|
|
42
|
+
}
|
|
43
|
+
else if (ch === ",") {
|
|
44
|
+
cells.push(current.trim());
|
|
45
|
+
current = "";
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
current += ch;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
cells.push(current.trim());
|
|
52
|
+
return cells;
|
|
53
|
+
}
|
|
54
|
+
function normalizeHeader(h) {
|
|
55
|
+
return h.trim().toLowerCase().replace(/[\s_]+/g, "");
|
|
56
|
+
}
|
|
57
|
+
function generatePassword(length = 12) {
|
|
58
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
|
59
|
+
const bytes = randomBytes(length);
|
|
60
|
+
let out = "";
|
|
61
|
+
for (let i = 0; i < length; i += 1) {
|
|
62
|
+
out += alphabet[bytes[i] % alphabet.length];
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function placeholderEmail(username) {
|
|
67
|
+
const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, "");
|
|
68
|
+
return `${safe || "student"}.${randomBytes(3).toString("hex")}@noemail.studious.local`;
|
|
69
|
+
}
|
|
70
|
+
export function parseStudentCsv(csvText) {
|
|
71
|
+
const lines = csvText
|
|
72
|
+
.replace(/^\uFEFF/, "")
|
|
73
|
+
.split(/\r?\n/)
|
|
74
|
+
.map((l) => l.trim())
|
|
75
|
+
.filter((l) => l.length > 0);
|
|
76
|
+
if (lines.length < 2) {
|
|
77
|
+
throw new TRPCError({
|
|
78
|
+
code: "BAD_REQUEST",
|
|
79
|
+
message: "CSV must include a header row and at least one data row",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const headers = parseCsvLine(lines[0]).map(normalizeHeader);
|
|
83
|
+
const rows = lines.slice(1).map((line) => {
|
|
84
|
+
const cells = parseCsvLine(line);
|
|
85
|
+
const row = {};
|
|
86
|
+
headers.forEach((h, i) => {
|
|
87
|
+
row[h] = cells[i] ?? "";
|
|
88
|
+
});
|
|
89
|
+
return row;
|
|
90
|
+
});
|
|
91
|
+
return { headers, rows };
|
|
92
|
+
}
|
|
93
|
+
export async function importStudentsCsv(userId, input) {
|
|
94
|
+
const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);
|
|
95
|
+
if (!membership) {
|
|
96
|
+
throw new TRPCError({
|
|
97
|
+
code: "FORBIDDEN",
|
|
98
|
+
message: "Only school admins can perform this action",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const { rows } = parseStudentCsv(input.csvText);
|
|
102
|
+
if (rows.length > 500) {
|
|
103
|
+
throw new TRPCError({
|
|
104
|
+
code: "BAD_REQUEST",
|
|
105
|
+
message: "CSV import is limited to 500 rows per request",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const results = [];
|
|
109
|
+
let created = 0;
|
|
110
|
+
let failed = 0;
|
|
111
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
112
|
+
const rowNum = i + 2; // 1-indexed + header
|
|
113
|
+
const row = rows[i];
|
|
114
|
+
const displayName = row.displayname || row.name || row.fullname || row.fullname || "";
|
|
115
|
+
const requestedUsername = row.username || "";
|
|
116
|
+
const requestedEmail = (row.email || "").toLowerCase();
|
|
117
|
+
const requestedPassword = row.password || "";
|
|
118
|
+
const classId = row.classid || row.class || "";
|
|
119
|
+
try {
|
|
120
|
+
if (!displayName && !requestedUsername && !requestedEmail) {
|
|
121
|
+
throw new Error("Row needs displayName, username, or email");
|
|
122
|
+
}
|
|
123
|
+
let username = requestedUsername;
|
|
124
|
+
let generatedUsername = false;
|
|
125
|
+
if (!username) {
|
|
126
|
+
username = await generateUniqueUsername(async (candidate) => {
|
|
127
|
+
const existing = await findUserByUsername(candidate);
|
|
128
|
+
return Boolean(existing);
|
|
129
|
+
});
|
|
130
|
+
generatedUsername = true;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const taken = await findUserByUsername(username);
|
|
134
|
+
if (taken) {
|
|
135
|
+
throw new Error(`Username "${username}" already taken`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let email = requestedEmail;
|
|
139
|
+
let placeholderEmailUsed = false;
|
|
140
|
+
if (!email) {
|
|
141
|
+
email = placeholderEmail(username);
|
|
142
|
+
placeholderEmailUsed = true;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const existing = await findUserByEmail(email);
|
|
146
|
+
if (existing) {
|
|
147
|
+
throw new Error(`Email "${email}" already in use`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let password = requestedPassword;
|
|
151
|
+
let generatedPassword = false;
|
|
152
|
+
if (!password) {
|
|
153
|
+
password = generatePassword();
|
|
154
|
+
generatedPassword = true;
|
|
155
|
+
}
|
|
156
|
+
if (classId) {
|
|
157
|
+
const cls = await prisma.class.findFirst({
|
|
158
|
+
where: { id: classId, schoolId: input.schoolId },
|
|
159
|
+
select: { id: true },
|
|
160
|
+
});
|
|
161
|
+
if (!cls) {
|
|
162
|
+
throw new Error(`classId "${classId}" not found in this school`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (input.dryRun) {
|
|
166
|
+
results.push({
|
|
167
|
+
ok: true,
|
|
168
|
+
row: rowNum,
|
|
169
|
+
userId: "dry-run",
|
|
170
|
+
username,
|
|
171
|
+
email,
|
|
172
|
+
password,
|
|
173
|
+
displayName: displayName || null,
|
|
174
|
+
classId: classId || null,
|
|
175
|
+
generatedUsername,
|
|
176
|
+
generatedPassword,
|
|
177
|
+
placeholderEmail: placeholderEmailUsed,
|
|
178
|
+
});
|
|
179
|
+
created += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const hashed = await hash(password, 10);
|
|
183
|
+
const user = await createUser({
|
|
184
|
+
username,
|
|
185
|
+
email,
|
|
186
|
+
password: hashed,
|
|
187
|
+
verified: true,
|
|
188
|
+
});
|
|
189
|
+
if (displayName) {
|
|
190
|
+
await upsertUserProfile(user.id, { displayName });
|
|
191
|
+
}
|
|
192
|
+
await upsertSchoolMembership({
|
|
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 } } },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
results.push({
|
|
204
|
+
ok: true,
|
|
205
|
+
row: rowNum,
|
|
206
|
+
userId: user.id,
|
|
207
|
+
username: user.username,
|
|
208
|
+
email: user.email,
|
|
209
|
+
password,
|
|
210
|
+
displayName: displayName || null,
|
|
211
|
+
classId: classId || null,
|
|
212
|
+
generatedUsername,
|
|
213
|
+
generatedPassword,
|
|
214
|
+
placeholderEmail: placeholderEmailUsed,
|
|
215
|
+
});
|
|
216
|
+
created += 1;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
failed += 1;
|
|
220
|
+
results.push({
|
|
221
|
+
ok: false,
|
|
222
|
+
row: rowNum,
|
|
223
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
dryRun: Boolean(input.dryRun),
|
|
229
|
+
created,
|
|
230
|
+
failed,
|
|
231
|
+
total: rows.length,
|
|
232
|
+
results,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=studentImport.js.map
|
|
236
|
+
//# debugId=ce1c4284-8c8b-5ddd-bed4-ff674956801e
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* School-admin branded transcripts / progress reports (A4 — Nepal pilot).
|
|
3
|
+
*
|
|
4
|
+
* Aggregates per-class gradebook grades + grading boundaries + attendance %
|
|
5
|
+
* into a pdf-lib PDF. Branding: school name + logo (letterhead optional when
|
|
6
|
+
* present on School; schema does not include it yet — do not block).
|
|
7
|
+
*
|
|
8
|
+
* AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or
|
|
9
|
+
* a free-text `schoolYear` string for the report header.
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
import { type TranscriptReportVariant } from "../lib/transcriptPdf.js";
|
|
16
|
+
export interface GenerateTranscriptInput {
|
|
17
|
+
schoolId: string;
|
|
18
|
+
studentId: string;
|
|
19
|
+
from?: Date;
|
|
20
|
+
to?: Date;
|
|
21
|
+
/**
|
|
22
|
+
* Free-text school year label (e.g. "2082 BS", "2025–2026").
|
|
23
|
+
* Prefer AcademicTerm (C1) once available.
|
|
24
|
+
*/
|
|
25
|
+
schoolYear?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function generateTranscript(userId: string, input: GenerateTranscriptInput): Promise<{
|
|
28
|
+
pdfBase64: string;
|
|
29
|
+
fileName: string;
|
|
30
|
+
variant: TranscriptReportVariant;
|
|
31
|
+
studentId: string;
|
|
32
|
+
studentName: string;
|
|
33
|
+
/** C1 blocker: AcademicTerm model does not exist yet. */
|
|
34
|
+
academicTermSupported: false;
|
|
35
|
+
attendance: import("./attendanceRate.js").AttendanceRateSummary;
|
|
36
|
+
classCount: number;
|
|
37
|
+
}>;
|
|
38
|
+
export declare function generateTranscripts(userId: string, input: {
|
|
39
|
+
schoolId: string;
|
|
40
|
+
studentIds: string[];
|
|
41
|
+
from?: Date;
|
|
42
|
+
to?: Date;
|
|
43
|
+
schoolYear?: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
transcripts: {
|
|
46
|
+
pdfBase64: string;
|
|
47
|
+
fileName: string;
|
|
48
|
+
variant: TranscriptReportVariant;
|
|
49
|
+
studentId: string;
|
|
50
|
+
studentName: string;
|
|
51
|
+
/** C1 blocker: AcademicTerm model does not exist yet. */
|
|
52
|
+
academicTermSupported: false;
|
|
53
|
+
attendance: import("./attendanceRate.js").AttendanceRateSummary;
|
|
54
|
+
classCount: number;
|
|
55
|
+
}[];
|
|
56
|
+
errors: {
|
|
57
|
+
studentId: string;
|
|
58
|
+
message: string;
|
|
59
|
+
}[];
|
|
60
|
+
}>;
|
|
61
|
+
//# sourceMappingURL=transcript.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transcript.d.ts","sourceRoot":"/","sources":["services/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;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;AAkID,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"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* School-admin branded transcripts / progress reports (A4 — Nepal pilot).
|
|
3
|
+
*
|
|
4
|
+
* Aggregates per-class gradebook grades + grading boundaries + attendance %
|
|
5
|
+
* into a pdf-lib PDF. Branding: school name + logo (letterhead optional when
|
|
6
|
+
* present on School; schema does not include it yet — do not block).
|
|
7
|
+
*
|
|
8
|
+
* AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or
|
|
9
|
+
* a free-text `schoolYear` string for the report header.
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
|
|
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]="c7eb65fe-e18f-5b5b-9897-ce16dc61c93d")}catch(e){}}();
|
|
17
|
+
import { TRPCError } from "@trpc/server";
|
|
18
|
+
import { prisma } from "../lib/prisma.js";
|
|
19
|
+
import { bucket } from "../lib/googleCloudStorage.js";
|
|
20
|
+
import { buildTranscriptPdf, isLevelBasedBoundary, } from "../lib/transcriptPdf.js";
|
|
21
|
+
import { findActiveSchoolAdminMembership, getSchoolById, } from "../models/school.js";
|
|
22
|
+
import { computeAttendanceRate } from "./attendanceRate.js";
|
|
23
|
+
import { computeStudentGradeInClass } from "./gradebook.js";
|
|
24
|
+
const MAX_BULK = 50;
|
|
25
|
+
async function requireSchoolAdmin(schoolId, userId) {
|
|
26
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
27
|
+
if (!membership) {
|
|
28
|
+
throw new TRPCError({
|
|
29
|
+
code: "FORBIDDEN",
|
|
30
|
+
message: "Only school admins can generate transcripts",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return membership;
|
|
34
|
+
}
|
|
35
|
+
async function loadFileBytes(path) {
|
|
36
|
+
if (!path)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const [bytes] = await bucket.file(path).download();
|
|
40
|
+
return new Uint8Array(bytes);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function formatPeriodLabel(from, to) {
|
|
47
|
+
if (!from && !to)
|
|
48
|
+
return null;
|
|
49
|
+
const fmt = (d) => d.toISOString().slice(0, 10);
|
|
50
|
+
if (from && to)
|
|
51
|
+
return `${fmt(from)} – ${fmt(to)}`;
|
|
52
|
+
if (from)
|
|
53
|
+
return `From ${fmt(from)}`;
|
|
54
|
+
return `Through ${fmt(to)}`;
|
|
55
|
+
}
|
|
56
|
+
function slugify(value) {
|
|
57
|
+
return value
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
+
.replace(/^-|-$/g, "")
|
|
61
|
+
.slice(0, 48);
|
|
62
|
+
}
|
|
63
|
+
async function fetchStudentAttendance(studentId, from, to) {
|
|
64
|
+
const range = {
|
|
65
|
+
...(from ? { gte: from } : {}),
|
|
66
|
+
...(to ? { lte: to } : {}),
|
|
67
|
+
};
|
|
68
|
+
const hasRange = Object.keys(range).length > 0;
|
|
69
|
+
// Same M2M present/late/absent read path as getStudentTimeline (A1-safe).
|
|
70
|
+
const attendance = await prisma.attendance.findMany({
|
|
71
|
+
where: {
|
|
72
|
+
OR: [
|
|
73
|
+
{ present: { some: { id: studentId } } },
|
|
74
|
+
{ late: { some: { id: studentId } } },
|
|
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 } },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return attendance.map((record) => ({
|
|
87
|
+
status: record.present.length
|
|
88
|
+
? "PRESENT"
|
|
89
|
+
: record.late.length
|
|
90
|
+
? "LATE"
|
|
91
|
+
: "ABSENT",
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
async function buildClassRows(schoolId, studentId) {
|
|
95
|
+
const classes = await prisma.class.findMany({
|
|
96
|
+
where: {
|
|
97
|
+
schoolId,
|
|
98
|
+
students: { some: { id: studentId } },
|
|
99
|
+
},
|
|
100
|
+
select: {
|
|
101
|
+
id: true,
|
|
102
|
+
name: true,
|
|
103
|
+
subject: true,
|
|
104
|
+
section: true,
|
|
105
|
+
},
|
|
106
|
+
orderBy: [{ subject: "asc" }, { name: "asc" }],
|
|
107
|
+
});
|
|
108
|
+
const rows = [];
|
|
109
|
+
let levelVotes = 0;
|
|
110
|
+
let letterVotes = 0;
|
|
111
|
+
for (const cls of classes) {
|
|
112
|
+
const grade = await computeStudentGradeInClass(cls.id, studentId);
|
|
113
|
+
if (!grade)
|
|
114
|
+
continue;
|
|
115
|
+
if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {
|
|
116
|
+
levelVotes += 1;
|
|
117
|
+
}
|
|
118
|
+
else if (grade.gradingBoundary) {
|
|
119
|
+
letterVotes += 1;
|
|
120
|
+
}
|
|
121
|
+
rows.push({
|
|
122
|
+
className: cls.name,
|
|
123
|
+
subject: cls.subject,
|
|
124
|
+
section: cls.section,
|
|
125
|
+
percentage: grade.result.percentage,
|
|
126
|
+
gradeLabel: grade.letterGrade,
|
|
127
|
+
categorySummaries: grade.result.categories.map((c) => ({
|
|
128
|
+
name: c.name,
|
|
129
|
+
average: c.average,
|
|
130
|
+
})),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const variant = levelVotes > 0 && levelVotes >= letterVotes ? "level" : "letter";
|
|
134
|
+
return { rows, variant };
|
|
135
|
+
}
|
|
136
|
+
export async function generateTranscript(userId, input) {
|
|
137
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
138
|
+
const school = await getSchoolById(input.schoolId);
|
|
139
|
+
if (!school) {
|
|
140
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
|
|
141
|
+
}
|
|
142
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
143
|
+
where: {
|
|
144
|
+
schoolId: input.schoolId,
|
|
145
|
+
userId: input.studentId,
|
|
146
|
+
status: "ACTIVE",
|
|
147
|
+
role: "STUDENT",
|
|
148
|
+
},
|
|
149
|
+
select: {
|
|
150
|
+
user: {
|
|
151
|
+
select: {
|
|
152
|
+
id: true,
|
|
153
|
+
username: true,
|
|
154
|
+
profile: { select: { displayName: true } },
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (!membership) {
|
|
160
|
+
throw new TRPCError({
|
|
161
|
+
code: "NOT_FOUND",
|
|
162
|
+
message: "Student is not an active student in this school",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const studentName = membership.user.profile?.displayName || membership.user.username;
|
|
166
|
+
const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([
|
|
167
|
+
buildClassRows(input.schoolId, input.studentId),
|
|
168
|
+
fetchStudentAttendance(input.studentId, input.from, input.to),
|
|
169
|
+
loadFileBytes(school.logo?.path),
|
|
170
|
+
]);
|
|
171
|
+
// Soft letterhead support when schema later adds it — never required.
|
|
172
|
+
const schoolAny = school;
|
|
173
|
+
const letterheadPath = schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;
|
|
174
|
+
const letterheadType = schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;
|
|
175
|
+
const letterheadBytes = await loadFileBytes(letterheadPath);
|
|
176
|
+
const attendance = computeAttendanceRate(attendanceStatuses);
|
|
177
|
+
const generatedAt = new Date();
|
|
178
|
+
const pdfBytes = await buildTranscriptPdf({
|
|
179
|
+
schoolName: school.name,
|
|
180
|
+
studentName,
|
|
181
|
+
studentUsername: membership.user.username,
|
|
182
|
+
schoolYear: input.schoolYear ?? null,
|
|
183
|
+
periodLabel: formatPeriodLabel(input.from, input.to),
|
|
184
|
+
variant,
|
|
185
|
+
classes: rows,
|
|
186
|
+
attendance,
|
|
187
|
+
logoBytes,
|
|
188
|
+
logoContentType: school.logo?.type ?? null,
|
|
189
|
+
letterheadBytes,
|
|
190
|
+
letterheadContentType: letterheadType,
|
|
191
|
+
generatedAt,
|
|
192
|
+
});
|
|
193
|
+
const yearSlug = input.schoolYear
|
|
194
|
+
? slugify(input.schoolYear)
|
|
195
|
+
: generatedAt.toISOString().slice(0, 10);
|
|
196
|
+
const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;
|
|
197
|
+
return {
|
|
198
|
+
pdfBase64: Buffer.from(pdfBytes).toString("base64"),
|
|
199
|
+
fileName,
|
|
200
|
+
variant,
|
|
201
|
+
studentId: input.studentId,
|
|
202
|
+
studentName,
|
|
203
|
+
/** C1 blocker: AcademicTerm model does not exist yet. */
|
|
204
|
+
academicTermSupported: false,
|
|
205
|
+
attendance,
|
|
206
|
+
classCount: rows.length,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export async function generateTranscripts(userId, input) {
|
|
210
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
211
|
+
const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);
|
|
212
|
+
if (uniqueIds.length === 0) {
|
|
213
|
+
throw new TRPCError({
|
|
214
|
+
code: "BAD_REQUEST",
|
|
215
|
+
message: "Select at least one student",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const results = [];
|
|
219
|
+
const errors = [];
|
|
220
|
+
for (const studentId of uniqueIds) {
|
|
221
|
+
try {
|
|
222
|
+
const result = await generateTranscript(userId, {
|
|
223
|
+
schoolId: input.schoolId,
|
|
224
|
+
studentId,
|
|
225
|
+
from: input.from,
|
|
226
|
+
to: input.to,
|
|
227
|
+
schoolYear: input.schoolYear,
|
|
228
|
+
});
|
|
229
|
+
results.push(result);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const message = error instanceof TRPCError
|
|
233
|
+
? error.message
|
|
234
|
+
: error instanceof Error
|
|
235
|
+
? error.message
|
|
236
|
+
: "Failed to generate transcript";
|
|
237
|
+
errors.push({ studentId, message });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { transcripts: results, errors };
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=transcript.js.map
|
|
243
|
+
//# debugId=c7eb65fe-e18f-5b5b-9897-ce16dc61c93d
|
|
@@ -0,0 +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":"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","debug_id":"c7eb65fe-e18f-5b5b-9897-ce16dc61c93d"}
|