@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,313 @@
|
|
|
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
|
+
import { TRPCError } from "@trpc/server";
|
|
17
|
+
import { prisma } from "../lib/prisma.js";
|
|
18
|
+
import { bucket } from "../lib/googleCloudStorage.js";
|
|
19
|
+
import {
|
|
20
|
+
buildTranscriptPdf,
|
|
21
|
+
isLevelBasedBoundary,
|
|
22
|
+
type TranscriptClassRow,
|
|
23
|
+
type TranscriptReportVariant,
|
|
24
|
+
} from "../lib/transcriptPdf.js";
|
|
25
|
+
import {
|
|
26
|
+
findActiveSchoolAdminMembership,
|
|
27
|
+
getSchoolById,
|
|
28
|
+
} from "../models/school.js";
|
|
29
|
+
import { computeAttendanceRate } from "./attendanceRate.js";
|
|
30
|
+
import { computeStudentGradeInClass } from "./gradebook.js";
|
|
31
|
+
|
|
32
|
+
const MAX_BULK = 50;
|
|
33
|
+
|
|
34
|
+
export interface GenerateTranscriptInput {
|
|
35
|
+
schoolId: string;
|
|
36
|
+
studentId: string;
|
|
37
|
+
from?: Date;
|
|
38
|
+
to?: Date;
|
|
39
|
+
/**
|
|
40
|
+
* Free-text school year label (e.g. "2082 BS", "2025–2026").
|
|
41
|
+
* Prefer AcademicTerm (C1) once available.
|
|
42
|
+
*/
|
|
43
|
+
schoolYear?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requireSchoolAdmin(schoolId: string, userId: string) {
|
|
47
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
48
|
+
if (!membership) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "FORBIDDEN",
|
|
51
|
+
message: "Only school admins can generate transcripts",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return membership;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadFileBytes(
|
|
58
|
+
path: string | null | undefined
|
|
59
|
+
): Promise<Uint8Array | null> {
|
|
60
|
+
if (!path) return null;
|
|
61
|
+
try {
|
|
62
|
+
const [bytes] = await bucket.file(path).download();
|
|
63
|
+
return new Uint8Array(bytes);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatPeriodLabel(from?: Date, to?: Date): string | null {
|
|
70
|
+
if (!from && !to) return null;
|
|
71
|
+
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
|
72
|
+
if (from && to) return `${fmt(from)} – ${fmt(to)}`;
|
|
73
|
+
if (from) return `From ${fmt(from)}`;
|
|
74
|
+
return `Through ${fmt(to!)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function slugify(value: string): string {
|
|
78
|
+
return value
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
81
|
+
.replace(/^-|-$/g, "")
|
|
82
|
+
.slice(0, 48);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchStudentAttendance(
|
|
86
|
+
studentId: string,
|
|
87
|
+
from?: Date,
|
|
88
|
+
to?: Date
|
|
89
|
+
) {
|
|
90
|
+
const range = {
|
|
91
|
+
...(from ? { gte: from } : {}),
|
|
92
|
+
...(to ? { lte: to } : {}),
|
|
93
|
+
};
|
|
94
|
+
const hasRange = Object.keys(range).length > 0;
|
|
95
|
+
|
|
96
|
+
// Same M2M present/late/absent read path as getStudentTimeline (A1-safe).
|
|
97
|
+
const attendance = await prisma.attendance.findMany({
|
|
98
|
+
where: {
|
|
99
|
+
OR: [
|
|
100
|
+
{ present: { some: { id: studentId } } },
|
|
101
|
+
{ late: { some: { id: studentId } } },
|
|
102
|
+
{ absent: { some: { id: studentId } } },
|
|
103
|
+
],
|
|
104
|
+
...(hasRange ? { date: range } : {}),
|
|
105
|
+
},
|
|
106
|
+
select: {
|
|
107
|
+
id: true,
|
|
108
|
+
present: { where: { id: studentId }, select: { id: true } },
|
|
109
|
+
late: { where: { id: studentId }, select: { id: true } },
|
|
110
|
+
absent: { where: { id: studentId }, select: { id: true } },
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return attendance.map((record) => ({
|
|
115
|
+
status: record.present.length
|
|
116
|
+
? ("PRESENT" as const)
|
|
117
|
+
: record.late.length
|
|
118
|
+
? ("LATE" as const)
|
|
119
|
+
: ("ABSENT" as const),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function buildClassRows(
|
|
124
|
+
schoolId: string,
|
|
125
|
+
studentId: string
|
|
126
|
+
): Promise<{ rows: TranscriptClassRow[]; variant: TranscriptReportVariant }> {
|
|
127
|
+
const classes = await prisma.class.findMany({
|
|
128
|
+
where: {
|
|
129
|
+
schoolId,
|
|
130
|
+
students: { some: { id: studentId } },
|
|
131
|
+
},
|
|
132
|
+
select: {
|
|
133
|
+
id: true,
|
|
134
|
+
name: true,
|
|
135
|
+
subject: true,
|
|
136
|
+
section: true,
|
|
137
|
+
},
|
|
138
|
+
orderBy: [{ subject: "asc" }, { name: "asc" }],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const rows: TranscriptClassRow[] = [];
|
|
142
|
+
let levelVotes = 0;
|
|
143
|
+
let letterVotes = 0;
|
|
144
|
+
|
|
145
|
+
for (const cls of classes) {
|
|
146
|
+
const grade = await computeStudentGradeInClass(cls.id, studentId);
|
|
147
|
+
if (!grade) continue;
|
|
148
|
+
|
|
149
|
+
if (isLevelBasedBoundary(grade.gradingBoundary?.structured)) {
|
|
150
|
+
levelVotes += 1;
|
|
151
|
+
} else if (grade.gradingBoundary) {
|
|
152
|
+
letterVotes += 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
rows.push({
|
|
156
|
+
className: cls.name,
|
|
157
|
+
subject: cls.subject,
|
|
158
|
+
section: cls.section,
|
|
159
|
+
percentage: grade.result.percentage,
|
|
160
|
+
gradeLabel: grade.letterGrade,
|
|
161
|
+
categorySummaries: grade.result.categories.map((c) => ({
|
|
162
|
+
name: c.name,
|
|
163
|
+
average: c.average,
|
|
164
|
+
})),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const variant: TranscriptReportVariant =
|
|
169
|
+
levelVotes > 0 && levelVotes >= letterVotes ? "level" : "letter";
|
|
170
|
+
|
|
171
|
+
return { rows, variant };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function generateTranscript(
|
|
175
|
+
userId: string,
|
|
176
|
+
input: GenerateTranscriptInput
|
|
177
|
+
) {
|
|
178
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
179
|
+
|
|
180
|
+
const school = await getSchoolById(input.schoolId);
|
|
181
|
+
if (!school) {
|
|
182
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
186
|
+
where: {
|
|
187
|
+
schoolId: input.schoolId,
|
|
188
|
+
userId: input.studentId,
|
|
189
|
+
status: "ACTIVE",
|
|
190
|
+
role: "STUDENT",
|
|
191
|
+
},
|
|
192
|
+
select: {
|
|
193
|
+
user: {
|
|
194
|
+
select: {
|
|
195
|
+
id: true,
|
|
196
|
+
username: true,
|
|
197
|
+
profile: { select: { displayName: true } },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
if (!membership) {
|
|
203
|
+
throw new TRPCError({
|
|
204
|
+
code: "NOT_FOUND",
|
|
205
|
+
message: "Student is not an active student in this school",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const studentName =
|
|
210
|
+
membership.user.profile?.displayName || membership.user.username;
|
|
211
|
+
|
|
212
|
+
const [{ rows, variant }, attendanceStatuses, logoBytes] = await Promise.all([
|
|
213
|
+
buildClassRows(input.schoolId, input.studentId),
|
|
214
|
+
fetchStudentAttendance(input.studentId, input.from, input.to),
|
|
215
|
+
loadFileBytes(school.logo?.path),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
// Soft letterhead support when schema later adds it — never required.
|
|
219
|
+
const schoolAny = school as {
|
|
220
|
+
letterhead?: { path?: string | null; type?: string | null } | null;
|
|
221
|
+
letterheadPath?: string | null;
|
|
222
|
+
letterheadType?: string | null;
|
|
223
|
+
};
|
|
224
|
+
const letterheadPath =
|
|
225
|
+
schoolAny.letterhead?.path ?? schoolAny.letterheadPath ?? null;
|
|
226
|
+
const letterheadType =
|
|
227
|
+
schoolAny.letterhead?.type ?? schoolAny.letterheadType ?? null;
|
|
228
|
+
const letterheadBytes = await loadFileBytes(letterheadPath);
|
|
229
|
+
|
|
230
|
+
const attendance = computeAttendanceRate(attendanceStatuses);
|
|
231
|
+
const generatedAt = new Date();
|
|
232
|
+
|
|
233
|
+
const pdfBytes = await buildTranscriptPdf({
|
|
234
|
+
schoolName: school.name,
|
|
235
|
+
studentName,
|
|
236
|
+
studentUsername: membership.user.username,
|
|
237
|
+
schoolYear: input.schoolYear ?? null,
|
|
238
|
+
periodLabel: formatPeriodLabel(input.from, input.to),
|
|
239
|
+
variant,
|
|
240
|
+
classes: rows,
|
|
241
|
+
attendance,
|
|
242
|
+
logoBytes,
|
|
243
|
+
logoContentType: school.logo?.type ?? null,
|
|
244
|
+
letterheadBytes,
|
|
245
|
+
letterheadContentType: letterheadType,
|
|
246
|
+
generatedAt,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const yearSlug = input.schoolYear
|
|
250
|
+
? slugify(input.schoolYear)
|
|
251
|
+
: generatedAt.toISOString().slice(0, 10);
|
|
252
|
+
const fileName = `progress-report-${slugify(studentName) || membership.user.username}-${yearSlug}.pdf`;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
pdfBase64: Buffer.from(pdfBytes).toString("base64"),
|
|
256
|
+
fileName,
|
|
257
|
+
variant,
|
|
258
|
+
studentId: input.studentId,
|
|
259
|
+
studentName,
|
|
260
|
+
/** C1 blocker: AcademicTerm model does not exist yet. */
|
|
261
|
+
academicTermSupported: false as const,
|
|
262
|
+
attendance,
|
|
263
|
+
classCount: rows.length,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function generateTranscripts(
|
|
268
|
+
userId: string,
|
|
269
|
+
input: {
|
|
270
|
+
schoolId: string;
|
|
271
|
+
studentIds: string[];
|
|
272
|
+
from?: Date;
|
|
273
|
+
to?: Date;
|
|
274
|
+
schoolYear?: string;
|
|
275
|
+
}
|
|
276
|
+
) {
|
|
277
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
278
|
+
|
|
279
|
+
const uniqueIds = [...new Set(input.studentIds)].slice(0, MAX_BULK);
|
|
280
|
+
if (uniqueIds.length === 0) {
|
|
281
|
+
throw new TRPCError({
|
|
282
|
+
code: "BAD_REQUEST",
|
|
283
|
+
message: "Select at least one student",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type TranscriptResult = Awaited<ReturnType<typeof generateTranscript>>;
|
|
288
|
+
const results: TranscriptResult[] = [];
|
|
289
|
+
const errors: { studentId: string; message: string }[] = [];
|
|
290
|
+
|
|
291
|
+
for (const studentId of uniqueIds) {
|
|
292
|
+
try {
|
|
293
|
+
const result = await generateTranscript(userId, {
|
|
294
|
+
schoolId: input.schoolId,
|
|
295
|
+
studentId,
|
|
296
|
+
from: input.from,
|
|
297
|
+
to: input.to,
|
|
298
|
+
schoolYear: input.schoolYear,
|
|
299
|
+
});
|
|
300
|
+
results.push(result);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const message =
|
|
303
|
+
error instanceof TRPCError
|
|
304
|
+
? error.message
|
|
305
|
+
: error instanceof Error
|
|
306
|
+
? error.message
|
|
307
|
+
: "Failed to generate transcript";
|
|
308
|
+
errors.push({ studentId, message });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { transcripts: results, errors };
|
|
313
|
+
}
|
|
@@ -308,6 +308,7 @@ export async function addQuestionToWorksheet(
|
|
|
308
308
|
markScheme?: object;
|
|
309
309
|
type: WorksheetQuestionType;
|
|
310
310
|
order?: number;
|
|
311
|
+
parentQuestionId?: string | null;
|
|
311
312
|
}
|
|
312
313
|
) {
|
|
313
314
|
const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
|
|
@@ -317,6 +318,24 @@ export async function addQuestionToWorksheet(
|
|
|
317
318
|
message: "Only class teachers can modify worksheet questions",
|
|
318
319
|
});
|
|
319
320
|
}
|
|
321
|
+
if (data.parentQuestionId) {
|
|
322
|
+
const parent = await findWorksheetQuestionByIdInWorksheet(
|
|
323
|
+
data.parentQuestionId,
|
|
324
|
+
worksheetId
|
|
325
|
+
);
|
|
326
|
+
if (!parent) {
|
|
327
|
+
throw new TRPCError({
|
|
328
|
+
code: "BAD_REQUEST",
|
|
329
|
+
message: "Parent question not found in this worksheet",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (parent.parentQuestionId) {
|
|
333
|
+
throw new TRPCError({
|
|
334
|
+
code: "BAD_REQUEST",
|
|
335
|
+
message: "Subquestions cannot themselves have children",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
320
339
|
return createWorksheetQuestion({
|
|
321
340
|
worksheetId,
|
|
322
341
|
...data,
|
|
@@ -459,6 +478,7 @@ export async function updateWorksheetQuestionRecord(
|
|
|
459
478
|
options?: object;
|
|
460
479
|
markScheme?: object;
|
|
461
480
|
type?: WorksheetQuestionType;
|
|
481
|
+
parentQuestionId?: string | null;
|
|
462
482
|
}
|
|
463
483
|
) {
|
|
464
484
|
const question = await findWorksheetQuestionByIdForTeacher(questionId, userId);
|
|
@@ -468,9 +488,100 @@ export async function updateWorksheetQuestionRecord(
|
|
|
468
488
|
message: "Only class teachers can update worksheet questions",
|
|
469
489
|
});
|
|
470
490
|
}
|
|
491
|
+
if (data.parentQuestionId) {
|
|
492
|
+
if (data.parentQuestionId === questionId) {
|
|
493
|
+
throw new TRPCError({
|
|
494
|
+
code: "BAD_REQUEST",
|
|
495
|
+
message: "A question cannot be its own parent",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
const parent = await findWorksheetQuestionByIdInWorksheet(
|
|
499
|
+
data.parentQuestionId,
|
|
500
|
+
worksheetId
|
|
501
|
+
);
|
|
502
|
+
if (!parent) {
|
|
503
|
+
throw new TRPCError({
|
|
504
|
+
code: "BAD_REQUEST",
|
|
505
|
+
message: "Parent question not found in this worksheet",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (parent.parentQuestionId) {
|
|
509
|
+
throw new TRPCError({
|
|
510
|
+
code: "BAD_REQUEST",
|
|
511
|
+
message: "Subquestions cannot themselves have children",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
471
515
|
return updateWorksheetQuestion(questionId, data);
|
|
472
516
|
}
|
|
473
517
|
|
|
518
|
+
/** Answering statistics for each question — shown in the teacher worksheet editor. */
|
|
519
|
+
export async function getWorksheetQuestionStats(
|
|
520
|
+
worksheetId: string,
|
|
521
|
+
userId: string
|
|
522
|
+
) {
|
|
523
|
+
const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
|
|
524
|
+
if (!worksheet) {
|
|
525
|
+
throw new TRPCError({
|
|
526
|
+
code: "FORBIDDEN",
|
|
527
|
+
message: "Only class teachers can view worksheet answering statistics",
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const questions = await prisma.worksheetQuestion.findMany({
|
|
532
|
+
where: { worksheetId },
|
|
533
|
+
select: {
|
|
534
|
+
id: true,
|
|
535
|
+
parentQuestionId: true,
|
|
536
|
+
order: true,
|
|
537
|
+
points: true,
|
|
538
|
+
question: true,
|
|
539
|
+
type: true,
|
|
540
|
+
studentResponses: {
|
|
541
|
+
select: {
|
|
542
|
+
id: true,
|
|
543
|
+
isCorrect: true,
|
|
544
|
+
points: true,
|
|
545
|
+
response: true,
|
|
546
|
+
studentWorksheetResponse: {
|
|
547
|
+
select: { submitted: true },
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
orderBy: { order: "asc" },
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return questions.map((q) => {
|
|
556
|
+
const responses = q.studentResponses;
|
|
557
|
+
const answered = responses.filter(
|
|
558
|
+
(r) => r.response !== null && r.response !== ""
|
|
559
|
+
);
|
|
560
|
+
const submitted = responses.filter(
|
|
561
|
+
(r) => r.studentWorksheetResponse?.submitted
|
|
562
|
+
);
|
|
563
|
+
const correct = answered.filter((r) => r.isCorrect).length;
|
|
564
|
+
return {
|
|
565
|
+
questionId: q.id,
|
|
566
|
+
parentQuestionId: q.parentQuestionId,
|
|
567
|
+
order: q.order,
|
|
568
|
+
type: q.type,
|
|
569
|
+
points: q.points,
|
|
570
|
+
promptPreview: q.question.slice(0, 120),
|
|
571
|
+
totalResponses: responses.length,
|
|
572
|
+
answeredCount: answered.length,
|
|
573
|
+
submittedCount: submitted.length,
|
|
574
|
+
correctCount: correct,
|
|
575
|
+
averagePoints:
|
|
576
|
+
answered.length === 0
|
|
577
|
+
? null
|
|
578
|
+
: answered.reduce((sum, r) => sum + (r.points ?? 0), 0) /
|
|
579
|
+
answered.length,
|
|
580
|
+
correctRate: answered.length === 0 ? null : correct / answered.length,
|
|
581
|
+
};
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
474
585
|
export async function deleteWorksheetQuestionRecord(
|
|
475
586
|
worksheetId: string,
|
|
476
587
|
questionId: string,
|
package/src/trpc.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { Request, Response } from 'express';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';
|
|
9
9
|
import { findUserBySessionToken } from './models/auth.js';
|
|
10
|
-
import {
|
|
10
|
+
import { assertAdminPanelPassword } from './utils/adminPanelAccess.js';
|
|
11
11
|
|
|
12
12
|
interface CreateContextOptions {
|
|
13
13
|
req: Request;
|
|
@@ -96,11 +96,11 @@ const adminPanelMiddleware = t.middleware(({ ctx, next }) => {
|
|
|
96
96
|
message: 'Not authenticated',
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
assertAdminPanelPassword(ctx.req);
|
|
100
100
|
return next({ ctx });
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
/** Authenticated +
|
|
103
|
+
/** Authenticated + fixed admin password header (see `assertAdminPanelPassword`). */
|
|
104
104
|
export const adminProcedure = protectedProcedure.use(adminPanelMiddleware);
|
|
105
105
|
|
|
106
106
|
export const protectedClassMemberProcedure = protectedProcedure
|
|
@@ -1,46 +1,23 @@
|
|
|
1
1
|
import { TRPCError } from '@trpc/server';
|
|
2
|
-
import {
|
|
2
|
+
import type { Request } from 'express';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
.split(/[\s,]+/)
|
|
9
|
-
.map((s) => s.trim())
|
|
10
|
-
.filter(Boolean),
|
|
11
|
-
);
|
|
12
|
-
}
|
|
4
|
+
/** Fixed password gating the admin tRPC routes. */
|
|
5
|
+
const ADMIN_PANEL_PASSWORD = 'alan6767';
|
|
6
|
+
|
|
7
|
+
const ADMIN_PASSWORD_HEADER = 'x-admin-password';
|
|
13
8
|
|
|
14
9
|
/**
|
|
15
|
-
* Admin tRPC routes
|
|
16
|
-
*
|
|
17
|
-
* unset value allows any authenticated user (local ops); a non-empty list is
|
|
18
|
-
* still enforced.
|
|
10
|
+
* Admin tRPC routes require the fixed admin password in the
|
|
11
|
+
* `x-admin-password` header (sent by the admin panel).
|
|
19
12
|
*/
|
|
20
|
-
export function
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
if (env.NODE_ENV === 'production') {
|
|
24
|
-
if (allowed.size === 0) {
|
|
25
|
-
throw new TRPCError({
|
|
26
|
-
code: 'FORBIDDEN',
|
|
27
|
-
message:
|
|
28
|
-
'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
if (!allowed.has(userId)) {
|
|
32
|
-
throw new TRPCError({
|
|
33
|
-
code: 'FORBIDDEN',
|
|
34
|
-
message: 'You are not allowed to use the admin API.',
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
13
|
+
export function assertAdminPanelPassword(req: Request): void {
|
|
14
|
+
const header = req.headers[ADMIN_PASSWORD_HEADER];
|
|
15
|
+
const provided = typeof header === 'string' ? header : header?.[0];
|
|
39
16
|
|
|
40
|
-
if (
|
|
17
|
+
if (provided !== ADMIN_PANEL_PASSWORD) {
|
|
41
18
|
throw new TRPCError({
|
|
42
19
|
code: 'FORBIDDEN',
|
|
43
|
-
message: '
|
|
20
|
+
message: 'Invalid admin password.',
|
|
44
21
|
});
|
|
45
22
|
}
|
|
46
23
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getAssignmentTypeDefaults,
|
|
4
|
+
resolveAssignmentBehavior,
|
|
5
|
+
} from "../../src/lib/assignmentTypeDefaults.js";
|
|
6
|
+
|
|
7
|
+
describe("getAssignmentTypeDefaults", () => {
|
|
8
|
+
it("configures QUIZ without late file spam by default", () => {
|
|
9
|
+
const quiz = getAssignmentTypeDefaults("QUIZ");
|
|
10
|
+
expect(quiz.acceptFiles).toBe(false);
|
|
11
|
+
expect(quiz.singleAttempt).toBe(true);
|
|
12
|
+
expect(quiz.allowLateSubmissions).toBe(false);
|
|
13
|
+
expect(quiz.acceptWorksheet).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("configures ESSAY for extended response", () => {
|
|
17
|
+
const essay = getAssignmentTypeDefaults("ESSAY");
|
|
18
|
+
expect(essay.acceptExtendedResponse).toBe(true);
|
|
19
|
+
expect(essay.acceptFiles).toBe(false);
|
|
20
|
+
expect(essay.singleAttempt).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("resolveAssignmentBehavior", () => {
|
|
25
|
+
it("applies QUIZ defaults when fields omitted", () => {
|
|
26
|
+
const resolved = resolveAssignmentBehavior({ type: "QUIZ" });
|
|
27
|
+
expect(resolved.acceptFiles).toBe(false);
|
|
28
|
+
expect(resolved.singleAttempt).toBe(true);
|
|
29
|
+
expect(resolved.allowLateSubmissions).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("lets teachers override QUIZ file acceptance", () => {
|
|
33
|
+
const resolved = resolveAssignmentBehavior({
|
|
34
|
+
type: "QUIZ",
|
|
35
|
+
acceptFiles: true,
|
|
36
|
+
acceptWorksheet: true,
|
|
37
|
+
});
|
|
38
|
+
expect(resolved.acceptFiles).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects zero deliverables", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
resolveAssignmentBehavior({
|
|
44
|
+
type: "HOMEWORK",
|
|
45
|
+
acceptFiles: false,
|
|
46
|
+
acceptExtendedResponse: false,
|
|
47
|
+
acceptWorksheet: false,
|
|
48
|
+
applyTypeDefaults: false,
|
|
49
|
+
})
|
|
50
|
+
).toThrow(/at least one deliverable/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatNpr, monthLabel } from "../../../src/lib/feePdfFormat.js";
|
|
3
|
+
|
|
4
|
+
describe("feePdfFormat helpers", () => {
|
|
5
|
+
it("formats NPR amounts", () => {
|
|
6
|
+
expect(formatNpr(1500)).toContain("1,500");
|
|
7
|
+
expect(formatNpr(1500)).toMatch(/^NPR /);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("labels calendar months in UTC", () => {
|
|
11
|
+
expect(monthLabel(2026, 1)).toBe("January 2026");
|
|
12
|
+
expect(monthLabel(2026, 8)).toBe("August 2026");
|
|
13
|
+
expect(monthLabel(2026, 12)).toBe("December 2026");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_BRAND_COLOR,
|
|
4
|
+
ID_CARD_HEIGHT,
|
|
5
|
+
ID_CARD_WIDTH,
|
|
6
|
+
parseHexColor,
|
|
7
|
+
toPngBytes,
|
|
8
|
+
} from "../../../src/lib/idCardPdf.js";
|
|
9
|
+
|
|
10
|
+
describe("idCardPdf helpers", () => {
|
|
11
|
+
it("exposes CR80 card dimensions", () => {
|
|
12
|
+
expect(ID_CARD_WIDTH).toBe(243);
|
|
13
|
+
expect(ID_CARD_HEIGHT).toBe(153);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses valid hex colors", () => {
|
|
17
|
+
const color = parseHexColor("#3B82F6");
|
|
18
|
+
expect(color.red).toBeCloseTo(0x3b / 255, 5);
|
|
19
|
+
expect(color.green).toBeCloseTo(0x82 / 255, 5);
|
|
20
|
+
expect(color.blue).toBeCloseTo(0xf6 / 255, 5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back for invalid or missing hex", () => {
|
|
24
|
+
const fallback = parseHexColor(DEFAULT_BRAND_COLOR);
|
|
25
|
+
expect(parseHexColor(null)).toEqual(fallback);
|
|
26
|
+
expect(parseHexColor("not-a-color")).toEqual(fallback);
|
|
27
|
+
expect(parseHexColor("#fff")).toEqual(fallback);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("converts a minimal PNG through sharp", async () => {
|
|
31
|
+
const tinyPng = Buffer.from(
|
|
32
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
33
|
+
"base64"
|
|
34
|
+
);
|
|
35
|
+
const out = await toPngBytes(tinyPng);
|
|
36
|
+
expect(out).not.toBeNull();
|
|
37
|
+
expect(out!.byteLength).toBeGreaterThan(20);
|
|
38
|
+
expect(Buffer.from(out!.slice(0, 4)).toString("hex")).toBe("89504e47");
|
|
39
|
+
});
|
|
40
|
+
});
|