@studious-lms/server 1.23.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/.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 +547 -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 +180 -0
- package/dist/models/marketplace.d.ts.map +1 -0
- package/dist/models/marketplace.js +116 -0
- package/dist/models/marketplace.js.map +1 -0
- package/dist/models/moduleItem.d.ts +308 -0
- package/dist/models/moduleItem.d.ts.map +1 -0
- package/dist/models/moduleItem.js +93 -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 +12024 -7778
- 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 +61 -0
- package/dist/routers/moduleItem.js.map +1 -0
- package/dist/routers/parent.d.ts +170 -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 +110 -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 +242 -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 +327 -10
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +389 -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 +34 -2
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +27 -4
- 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 +330 -0
- package/dist/services/fees.js.map +1 -0
- package/dist/services/feesAuthz.d.ts +46 -0
- package/dist/services/feesAuthz.d.ts.map +1 -0
- package/dist/services/feesAuthz.js +49 -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 +631 -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 +204 -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 +131 -0
- package/dist/services/parentPortal.d.ts.map +1 -0
- package/dist/services/parentPortal.js +314 -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 +98 -54
- 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 +249 -0
- package/dist/services/studentImport.js.map +1 -0
- package/dist/services/transcript.d.ts +60 -0
- package/dist/services/transcript.d.ts.map +1 -0
- package/dist/services/transcript.js +231 -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 +402 -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 +702 -73
- package/src/models/class.ts +1 -0
- package/src/models/fees.ts +349 -0
- package/src/models/marketplace.ts +161 -0
- package/src/models/moduleItem.ts +125 -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 +91 -0
- package/src/routers/parent.ts +68 -0
- package/src/routers/school.ts +149 -1
- package/src/routers/worksheet.ts +11 -0
- package/src/services/academicCalendar.ts +327 -0
- package/src/services/assignment.ts +122 -12
- package/src/services/attendance.ts +552 -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 +26 -2
- package/src/services/fees.ts +447 -0
- package/src/services/feesAuthz.ts +73 -0
- package/src/services/gradebook.ts +70 -0
- package/src/services/idCard.ts +147 -0
- package/src/services/marketplace.ts +790 -0
- package/src/services/moduleItem.ts +298 -0
- package/src/services/notification.ts +43 -0
- package/src/services/parentPortal.ts +367 -0
- package/src/services/school.ts +100 -54
- package/src/services/studentImport.ts +285 -0
- package/src/services/transcript.ts +301 -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
package/src/services/school.ts
CHANGED
|
@@ -85,6 +85,9 @@ export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
|
85
85
|
// signed URL through trpc.file.getSignedUrl when needed.
|
|
86
86
|
logoPath: activeMembership.school.logo?.path ?? null,
|
|
87
87
|
logoType: activeMembership.school.logo?.type ?? null,
|
|
88
|
+
letterheadFileId: activeMembership.school.letterheadFileId ?? null,
|
|
89
|
+
letterheadPath: activeMembership.school.letterhead?.path ?? null,
|
|
90
|
+
letterheadType: activeMembership.school.letterhead?.type ?? null,
|
|
88
91
|
}
|
|
89
92
|
: null;
|
|
90
93
|
|
|
@@ -126,14 +129,38 @@ export async function updateSchoolDetails(
|
|
|
126
129
|
description?: string | null;
|
|
127
130
|
subdomain?: string | null;
|
|
128
131
|
logoId?: string | null;
|
|
132
|
+
letterheadFileId?: string | null;
|
|
129
133
|
}
|
|
130
134
|
) {
|
|
131
135
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
136
|
+
const brandingFileIds = [input.logoId, input.letterheadFileId].filter(
|
|
137
|
+
(id): id is string => typeof id === "string"
|
|
138
|
+
);
|
|
139
|
+
if (brandingFileIds.length) {
|
|
140
|
+
const accessible = await prisma.file.findMany({
|
|
141
|
+
where: {
|
|
142
|
+
id: { in: brandingFileIds },
|
|
143
|
+
OR: [
|
|
144
|
+
{ userId },
|
|
145
|
+
{ folder: { schoolStorageId: input.schoolId } },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
select: { id: true },
|
|
149
|
+
});
|
|
150
|
+
const accessibleIds = new Set(accessible.map((f) => f.id));
|
|
151
|
+
if (brandingFileIds.some((id) => !accessibleIds.has(id))) {
|
|
152
|
+
throw new TRPCError({
|
|
153
|
+
code: "FORBIDDEN",
|
|
154
|
+
message: "File does not belong to this school",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
132
158
|
const updated = await updateSchoolRecord(input.schoolId, {
|
|
133
159
|
name: input.name,
|
|
134
160
|
description: input.description,
|
|
135
161
|
subdomain: input.subdomain,
|
|
136
162
|
logoId: input.logoId,
|
|
163
|
+
letterheadFileId: input.letterheadFileId,
|
|
137
164
|
});
|
|
138
165
|
return { school: updated };
|
|
139
166
|
}
|
|
@@ -153,7 +180,13 @@ export async function getAdminOverview(userId: string, schoolId: string) {
|
|
|
153
180
|
counts[membership.role] += 1;
|
|
154
181
|
return counts;
|
|
155
182
|
},
|
|
156
|
-
{
|
|
183
|
+
{
|
|
184
|
+
ADMIN: 0,
|
|
185
|
+
TEACHER: 0,
|
|
186
|
+
STUDENT: 0,
|
|
187
|
+
ACCOUNTANT: 0,
|
|
188
|
+
PARENT: 0,
|
|
189
|
+
} satisfies Record<SchoolMembershipRole, number>
|
|
157
190
|
);
|
|
158
191
|
|
|
159
192
|
return {
|
|
@@ -523,7 +556,7 @@ export async function searchMembers(
|
|
|
523
556
|
schoolId: string,
|
|
524
557
|
query: string,
|
|
525
558
|
limit = 10,
|
|
526
|
-
role?:
|
|
559
|
+
role?: SchoolMembershipRole
|
|
527
560
|
) {
|
|
528
561
|
await requireSchoolMember(schoolId, userId);
|
|
529
562
|
const trimmed = query.trim();
|
|
@@ -617,17 +650,18 @@ export async function getStudentTimeline(
|
|
|
617
650
|
});
|
|
618
651
|
}
|
|
619
652
|
|
|
653
|
+
// Default to the last 12 months when no range is given, to bound reads.
|
|
654
|
+
const defaultFrom = new Date();
|
|
655
|
+
defaultFrom.setFullYear(defaultFrom.getFullYear() - 1);
|
|
620
656
|
const range = {
|
|
621
|
-
|
|
657
|
+
gte: input.from ?? defaultFrom,
|
|
622
658
|
...(input.to ? { lte: input.to } : {}),
|
|
623
659
|
};
|
|
624
|
-
const hasRange = Object.keys(range).length > 0;
|
|
625
|
-
|
|
626
660
|
const [events, assignments, attendance] = await Promise.all([
|
|
627
661
|
prisma.event.findMany({
|
|
628
662
|
where: {
|
|
629
663
|
userId: input.studentId,
|
|
630
|
-
|
|
664
|
+
startTime: range,
|
|
631
665
|
},
|
|
632
666
|
select: {
|
|
633
667
|
id: true,
|
|
@@ -647,7 +681,7 @@ export async function getStudentTimeline(
|
|
|
647
681
|
students: { some: { id: input.studentId } },
|
|
648
682
|
},
|
|
649
683
|
template: false,
|
|
650
|
-
|
|
684
|
+
dueDate: range,
|
|
651
685
|
},
|
|
652
686
|
select: {
|
|
653
687
|
id: true,
|
|
@@ -679,36 +713,41 @@ export async function getStudentTimeline(
|
|
|
679
713
|
},
|
|
680
714
|
orderBy: { dueDate: "asc" },
|
|
681
715
|
}),
|
|
682
|
-
prisma.
|
|
716
|
+
prisma.attendanceRecord.findMany({
|
|
683
717
|
where: {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
...(hasRange ? { date: range } : {}),
|
|
718
|
+
studentId: input.studentId,
|
|
719
|
+
attendance: {
|
|
720
|
+
class: { schoolId: input.schoolId },
|
|
721
|
+
date: range,
|
|
722
|
+
},
|
|
690
723
|
},
|
|
691
724
|
select: {
|
|
692
725
|
id: true,
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
726
|
+
status: true,
|
|
727
|
+
note: true,
|
|
728
|
+
excusedByExcusalId: true,
|
|
729
|
+
attendance: {
|
|
730
|
+
select: {
|
|
731
|
+
id: true,
|
|
732
|
+
date: true,
|
|
733
|
+
class: { select: { id: true, name: true, color: true } },
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
excusedByExcusal: { select: { id: true, reason: true } },
|
|
698
737
|
},
|
|
699
|
-
orderBy: { date: "asc" },
|
|
738
|
+
orderBy: { attendance: { date: "asc" } },
|
|
700
739
|
}),
|
|
701
740
|
]);
|
|
702
741
|
|
|
703
742
|
const attendanceShaped = attendance.map((record) => ({
|
|
704
|
-
id: record.id,
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
743
|
+
id: record.attendance.id,
|
|
744
|
+
recordId: record.id,
|
|
745
|
+
date: record.attendance.date,
|
|
746
|
+
class: record.attendance.class,
|
|
747
|
+
status: record.status,
|
|
748
|
+
note: record.note,
|
|
749
|
+
lockedFromExcusal: Boolean(record.excusedByExcusalId),
|
|
750
|
+
reason: record.excusedByExcusal?.reason ?? null,
|
|
712
751
|
}));
|
|
713
752
|
|
|
714
753
|
return {
|
|
@@ -770,11 +809,13 @@ export async function getMemberTimeline(
|
|
|
770
809
|
});
|
|
771
810
|
}
|
|
772
811
|
|
|
812
|
+
// Default to the last 12 months when no range is given, to bound reads.
|
|
813
|
+
const defaultFrom = new Date();
|
|
814
|
+
defaultFrom.setFullYear(defaultFrom.getFullYear() - 1);
|
|
773
815
|
const range = {
|
|
774
|
-
|
|
816
|
+
gte: input.from ?? defaultFrom,
|
|
775
817
|
...(input.to ? { lte: input.to } : {}),
|
|
776
818
|
};
|
|
777
|
-
const hasRange = Object.keys(range).length > 0;
|
|
778
819
|
|
|
779
820
|
if (membership.role === "STUDENT") {
|
|
780
821
|
if (!callerIsAdmin) {
|
|
@@ -798,7 +839,7 @@ export async function getMemberTimeline(
|
|
|
798
839
|
prisma.event.findMany({
|
|
799
840
|
where: {
|
|
800
841
|
userId: input.memberId,
|
|
801
|
-
|
|
842
|
+
startTime: range,
|
|
802
843
|
},
|
|
803
844
|
select: {
|
|
804
845
|
id: true,
|
|
@@ -816,7 +857,7 @@ export async function getMemberTimeline(
|
|
|
816
857
|
where: {
|
|
817
858
|
class: { students: { some: { id: input.memberId } } },
|
|
818
859
|
template: false,
|
|
819
|
-
|
|
860
|
+
dueDate: range,
|
|
820
861
|
},
|
|
821
862
|
select: {
|
|
822
863
|
id: true,
|
|
@@ -848,36 +889,41 @@ export async function getMemberTimeline(
|
|
|
848
889
|
},
|
|
849
890
|
orderBy: { dueDate: "asc" },
|
|
850
891
|
}),
|
|
851
|
-
prisma.
|
|
892
|
+
prisma.attendanceRecord.findMany({
|
|
852
893
|
where: {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
...(hasRange ? { date: range } : {}),
|
|
894
|
+
studentId: input.memberId,
|
|
895
|
+
attendance: {
|
|
896
|
+
class: { schoolId: input.schoolId },
|
|
897
|
+
date: range,
|
|
898
|
+
},
|
|
859
899
|
},
|
|
860
900
|
select: {
|
|
861
901
|
id: true,
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
902
|
+
status: true,
|
|
903
|
+
note: true,
|
|
904
|
+
excusedByExcusalId: true,
|
|
905
|
+
attendance: {
|
|
906
|
+
select: {
|
|
907
|
+
id: true,
|
|
908
|
+
date: true,
|
|
909
|
+
class: { select: { id: true, name: true, color: true } },
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
excusedByExcusal: { select: { id: true, reason: true } },
|
|
867
913
|
},
|
|
868
|
-
orderBy: { date: "asc" },
|
|
914
|
+
orderBy: { attendance: { date: "asc" } },
|
|
869
915
|
}),
|
|
870
916
|
]);
|
|
871
917
|
|
|
872
918
|
const attendanceShaped = attendance.map((record) => ({
|
|
873
|
-
id: record.id,
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
919
|
+
id: record.attendance.id,
|
|
920
|
+
recordId: record.id,
|
|
921
|
+
date: record.attendance.date,
|
|
922
|
+
class: record.attendance.class,
|
|
923
|
+
status: record.status,
|
|
924
|
+
note: record.note,
|
|
925
|
+
lockedFromExcusal: Boolean(record.excusedByExcusalId),
|
|
926
|
+
reason: record.excusedByExcusal?.reason ?? null,
|
|
881
927
|
}));
|
|
882
928
|
|
|
883
929
|
return {
|
|
@@ -904,7 +950,7 @@ export async function getMemberTimeline(
|
|
|
904
950
|
{ userId: input.memberId },
|
|
905
951
|
{ class: { teachers: { some: { id: input.memberId } } } },
|
|
906
952
|
],
|
|
907
|
-
|
|
953
|
+
startTime: range,
|
|
908
954
|
},
|
|
909
955
|
select: {
|
|
910
956
|
id: true,
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
import { randomBytes } from "crypto";
|
|
11
|
+
import { hash } from "bcryptjs";
|
|
12
|
+
import { TRPCError } from "@trpc/server";
|
|
13
|
+
import { prisma } from "../lib/prisma.js";
|
|
14
|
+
import { findUserByEmail, findUserByUsername } from "../models/auth.js";
|
|
15
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
16
|
+
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
17
|
+
|
|
18
|
+
export type StudentCsvRowResult =
|
|
19
|
+
| {
|
|
20
|
+
ok: true;
|
|
21
|
+
row: number;
|
|
22
|
+
userId: string;
|
|
23
|
+
username: string;
|
|
24
|
+
email: string;
|
|
25
|
+
password: string;
|
|
26
|
+
displayName: string | null;
|
|
27
|
+
classId: string | null;
|
|
28
|
+
generatedUsername: boolean;
|
|
29
|
+
generatedPassword: boolean;
|
|
30
|
+
placeholderEmail: boolean;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
row: number;
|
|
35
|
+
error: string;
|
|
36
|
+
raw?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function parseCsvLine(line: string): string[] {
|
|
40
|
+
const cells: string[] = [];
|
|
41
|
+
let current = "";
|
|
42
|
+
let inQuotes = false;
|
|
43
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
44
|
+
const ch = line[i];
|
|
45
|
+
if (inQuotes) {
|
|
46
|
+
if (ch === '"') {
|
|
47
|
+
if (line[i + 1] === '"') {
|
|
48
|
+
current += '"';
|
|
49
|
+
i += 1;
|
|
50
|
+
} else {
|
|
51
|
+
inQuotes = false;
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
current += ch;
|
|
55
|
+
}
|
|
56
|
+
} else if (ch === '"') {
|
|
57
|
+
inQuotes = true;
|
|
58
|
+
} else if (ch === ",") {
|
|
59
|
+
cells.push(current.trim());
|
|
60
|
+
current = "";
|
|
61
|
+
} else {
|
|
62
|
+
current += ch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
cells.push(current.trim());
|
|
66
|
+
return cells;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeHeader(h: string) {
|
|
70
|
+
return h.trim().toLowerCase().replace(/[\s_]+/g, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function generatePassword(length = 12) {
|
|
74
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
|
75
|
+
const bytes = randomBytes(length);
|
|
76
|
+
let out = "";
|
|
77
|
+
for (let i = 0; i < length; i += 1) {
|
|
78
|
+
out += alphabet[bytes[i]! % alphabet.length];
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function placeholderEmail(username: string) {
|
|
84
|
+
const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, "");
|
|
85
|
+
return `${safe || "student"}.${randomBytes(3).toString("hex")}@noemail.studious.local`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseStudentCsv(csvText: string): {
|
|
89
|
+
headers: string[];
|
|
90
|
+
rows: Record<string, string>[];
|
|
91
|
+
} {
|
|
92
|
+
const lines = csvText
|
|
93
|
+
.replace(/^\uFEFF/, "")
|
|
94
|
+
.split(/\r?\n/)
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.filter((l) => l.length > 0);
|
|
97
|
+
if (lines.length < 2) {
|
|
98
|
+
throw new TRPCError({
|
|
99
|
+
code: "BAD_REQUEST",
|
|
100
|
+
message: "CSV must include a header row and at least one data row",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const headers = parseCsvLine(lines[0]!).map(normalizeHeader);
|
|
104
|
+
const rows = lines.slice(1).map((line) => {
|
|
105
|
+
const cells = parseCsvLine(line);
|
|
106
|
+
const row: Record<string, string> = {};
|
|
107
|
+
headers.forEach((h, i) => {
|
|
108
|
+
row[h] = cells[i] ?? "";
|
|
109
|
+
});
|
|
110
|
+
return row;
|
|
111
|
+
});
|
|
112
|
+
return { headers, rows };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function importStudentsCsv(
|
|
116
|
+
userId: string,
|
|
117
|
+
input: { schoolId: string; csvText: string; dryRun?: boolean }
|
|
118
|
+
) {
|
|
119
|
+
const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);
|
|
120
|
+
if (!membership) {
|
|
121
|
+
throw new TRPCError({
|
|
122
|
+
code: "FORBIDDEN",
|
|
123
|
+
message: "Only school admins can perform this action",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { rows } = parseStudentCsv(input.csvText);
|
|
128
|
+
if (rows.length > 500) {
|
|
129
|
+
throw new TRPCError({
|
|
130
|
+
code: "BAD_REQUEST",
|
|
131
|
+
message: "CSV import is limited to 500 rows per request",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const results: StudentCsvRowResult[] = [];
|
|
136
|
+
let created = 0;
|
|
137
|
+
let failed = 0;
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
140
|
+
const rowNum = i + 2; // 1-indexed + header
|
|
141
|
+
const row = rows[i]!;
|
|
142
|
+
const displayName = row.displayname || row.name || row.fullname || "";
|
|
143
|
+
const requestedUsername = row.username || "";
|
|
144
|
+
const requestedEmail = (row.email || "").toLowerCase();
|
|
145
|
+
const requestedPassword = row.password || "";
|
|
146
|
+
const classId = row.classid || row.class || "";
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (!displayName && !requestedUsername && !requestedEmail) {
|
|
150
|
+
throw new Error("Row needs displayName, username, or email");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let username = requestedUsername;
|
|
154
|
+
let generatedUsername = false;
|
|
155
|
+
if (!username) {
|
|
156
|
+
username = await generateUniqueUsername(async (candidate) => {
|
|
157
|
+
const existing = await findUserByUsername(candidate);
|
|
158
|
+
return Boolean(existing);
|
|
159
|
+
});
|
|
160
|
+
generatedUsername = true;
|
|
161
|
+
} else {
|
|
162
|
+
const taken = await findUserByUsername(username);
|
|
163
|
+
if (taken) {
|
|
164
|
+
throw new Error(`Username "${username}" already taken`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let email = requestedEmail;
|
|
169
|
+
let placeholderEmailUsed = false;
|
|
170
|
+
if (!email) {
|
|
171
|
+
email = placeholderEmail(username);
|
|
172
|
+
placeholderEmailUsed = true;
|
|
173
|
+
} else {
|
|
174
|
+
const existing = await findUserByEmail(email);
|
|
175
|
+
if (existing) {
|
|
176
|
+
throw new Error(`Email "${email}" already in use`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let password = requestedPassword;
|
|
181
|
+
let generatedPassword = false;
|
|
182
|
+
if (!password) {
|
|
183
|
+
password = generatePassword();
|
|
184
|
+
generatedPassword = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (classId) {
|
|
188
|
+
const cls = await prisma.class.findFirst({
|
|
189
|
+
where: { id: classId, schoolId: input.schoolId },
|
|
190
|
+
select: { id: true },
|
|
191
|
+
});
|
|
192
|
+
if (!cls) {
|
|
193
|
+
throw new Error(`classId "${classId}" not found in this school`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (input.dryRun) {
|
|
198
|
+
results.push({
|
|
199
|
+
ok: true,
|
|
200
|
+
row: rowNum,
|
|
201
|
+
userId: "dry-run",
|
|
202
|
+
username,
|
|
203
|
+
email,
|
|
204
|
+
password,
|
|
205
|
+
displayName: displayName || null,
|
|
206
|
+
classId: classId || null,
|
|
207
|
+
generatedUsername,
|
|
208
|
+
generatedPassword,
|
|
209
|
+
placeholderEmail: placeholderEmailUsed,
|
|
210
|
+
});
|
|
211
|
+
created += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const hashed = await hash(password, 10);
|
|
216
|
+
const user = await prisma.$transaction(async (tx) => {
|
|
217
|
+
const createdUser = await tx.user.create({
|
|
218
|
+
data: {
|
|
219
|
+
username,
|
|
220
|
+
email,
|
|
221
|
+
password: hashed,
|
|
222
|
+
verified: true,
|
|
223
|
+
profile: { create: displayName ? { displayName } : {} },
|
|
224
|
+
},
|
|
225
|
+
select: { id: true, username: true, email: true },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await tx.schoolMembership.upsert({
|
|
229
|
+
where: {
|
|
230
|
+
schoolId_userId: {
|
|
231
|
+
schoolId: input.schoolId,
|
|
232
|
+
userId: createdUser.id,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
update: { role: "STUDENT", status: "ACTIVE" },
|
|
236
|
+
create: {
|
|
237
|
+
schoolId: input.schoolId,
|
|
238
|
+
userId: createdUser.id,
|
|
239
|
+
role: "STUDENT",
|
|
240
|
+
status: "ACTIVE",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (classId) {
|
|
245
|
+
await tx.class.update({
|
|
246
|
+
where: { id: classId },
|
|
247
|
+
data: { students: { connect: { id: createdUser.id } } },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return createdUser;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
results.push({
|
|
255
|
+
ok: true,
|
|
256
|
+
row: rowNum,
|
|
257
|
+
userId: user.id,
|
|
258
|
+
username: user.username,
|
|
259
|
+
email: user.email,
|
|
260
|
+
password,
|
|
261
|
+
displayName: displayName || null,
|
|
262
|
+
classId: classId || null,
|
|
263
|
+
generatedUsername,
|
|
264
|
+
generatedPassword,
|
|
265
|
+
placeholderEmail: placeholderEmailUsed,
|
|
266
|
+
});
|
|
267
|
+
created += 1;
|
|
268
|
+
} catch (err) {
|
|
269
|
+
failed += 1;
|
|
270
|
+
results.push({
|
|
271
|
+
ok: false,
|
|
272
|
+
row: rowNum,
|
|
273
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
dryRun: Boolean(input.dryRun),
|
|
280
|
+
created,
|
|
281
|
+
failed,
|
|
282
|
+
total: rows.length,
|
|
283
|
+
results,
|
|
284
|
+
};
|
|
285
|
+
}
|