@studious-lms/server 1.22.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 +193 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +80 -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 +11605 -7133
- 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 +476 -6
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +130 -4
- 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 +122 -3
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +179 -47
- 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 +95 -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 +184 -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 +243 -45
- 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
package/src/services/school.ts
CHANGED
|
@@ -23,17 +23,24 @@ import {
|
|
|
23
23
|
countActiveAdmins,
|
|
24
24
|
createSchoolInvite,
|
|
25
25
|
createSchoolWithAdmin,
|
|
26
|
+
deleteSchoolClassRecord,
|
|
26
27
|
findActiveSchoolAdminMembership,
|
|
27
28
|
findPendingInviteByToken,
|
|
29
|
+
findSchoolClass,
|
|
28
30
|
findSchoolMembership,
|
|
29
31
|
getSchoolById,
|
|
32
|
+
getSchoolMemberDetail,
|
|
30
33
|
getSchoolOverview,
|
|
34
|
+
listActiveSchoolMemberships,
|
|
35
|
+
listMemberClasses,
|
|
36
|
+
listRecentSchoolMembers,
|
|
31
37
|
listSchoolClasses,
|
|
32
38
|
listSchoolInvites,
|
|
33
39
|
listSchoolMembers,
|
|
34
40
|
listUserSchoolMemberships,
|
|
35
41
|
removeSchoolMembershipPreservingContent,
|
|
36
42
|
revokeSchoolInvite,
|
|
43
|
+
updateSchoolClassMembers,
|
|
37
44
|
updateSchoolMembershipRole,
|
|
38
45
|
updateSchoolRecord,
|
|
39
46
|
upsertSchoolMembership,
|
|
@@ -78,6 +85,9 @@ export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
|
78
85
|
// signed URL through trpc.file.getSignedUrl when needed.
|
|
79
86
|
logoPath: activeMembership.school.logo?.path ?? null,
|
|
80
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,
|
|
81
91
|
}
|
|
82
92
|
: null;
|
|
83
93
|
|
|
@@ -119,6 +129,7 @@ export async function updateSchoolDetails(
|
|
|
119
129
|
description?: string | null;
|
|
120
130
|
subdomain?: string | null;
|
|
121
131
|
logoId?: string | null;
|
|
132
|
+
letterheadFileId?: string | null;
|
|
122
133
|
}
|
|
123
134
|
) {
|
|
124
135
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
@@ -127,13 +138,17 @@ export async function updateSchoolDetails(
|
|
|
127
138
|
description: input.description,
|
|
128
139
|
subdomain: input.subdomain,
|
|
129
140
|
logoId: input.logoId,
|
|
141
|
+
letterheadFileId: input.letterheadFileId,
|
|
130
142
|
});
|
|
131
143
|
return { school: updated };
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
export async function getAdminOverview(userId: string, schoolId: string) {
|
|
135
147
|
await requireSchoolAdmin(schoolId, userId);
|
|
136
|
-
const school = await
|
|
148
|
+
const [school, recentMembers] = await Promise.all([
|
|
149
|
+
getSchoolOverview(schoolId),
|
|
150
|
+
listRecentSchoolMembers(schoolId),
|
|
151
|
+
]);
|
|
137
152
|
if (!school) {
|
|
138
153
|
throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
|
|
139
154
|
}
|
|
@@ -143,7 +158,13 @@ export async function getAdminOverview(userId: string, schoolId: string) {
|
|
|
143
158
|
counts[membership.role] += 1;
|
|
144
159
|
return counts;
|
|
145
160
|
},
|
|
146
|
-
{
|
|
161
|
+
{
|
|
162
|
+
ADMIN: 0,
|
|
163
|
+
TEACHER: 0,
|
|
164
|
+
STUDENT: 0,
|
|
165
|
+
ACCOUNTANT: 0,
|
|
166
|
+
PARENT: 0,
|
|
167
|
+
} satisfies Record<SchoolMembershipRole, number>
|
|
147
168
|
);
|
|
148
169
|
|
|
149
170
|
return {
|
|
@@ -159,6 +180,7 @@ export async function getAdminOverview(userId: string, schoolId: string) {
|
|
|
159
180
|
},
|
|
160
181
|
recentClasses: school.classes,
|
|
161
182
|
recentInvites: school.invites,
|
|
183
|
+
recentMembers,
|
|
162
184
|
},
|
|
163
185
|
};
|
|
164
186
|
}
|
|
@@ -265,6 +287,172 @@ export async function updateMemberRole(
|
|
|
265
287
|
return { membership };
|
|
266
288
|
}
|
|
267
289
|
|
|
290
|
+
export async function getMemberDetail(
|
|
291
|
+
userId: string,
|
|
292
|
+
input: { schoolId: string; memberUserId: string }
|
|
293
|
+
) {
|
|
294
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
295
|
+
const membership = await getSchoolMemberDetail(
|
|
296
|
+
input.schoolId,
|
|
297
|
+
input.memberUserId
|
|
298
|
+
);
|
|
299
|
+
if (!membership) {
|
|
300
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const [teachingClasses, enrolledClasses] = await listMemberClasses(
|
|
304
|
+
input.schoolId,
|
|
305
|
+
input.memberUserId
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const toClassSummary = (cls: (typeof teachingClasses)[number]) => ({
|
|
309
|
+
id: cls.id,
|
|
310
|
+
name: cls.name,
|
|
311
|
+
section: cls.section,
|
|
312
|
+
subject: cls.subject,
|
|
313
|
+
color: cls.color,
|
|
314
|
+
teacherCount: cls._count.teachers,
|
|
315
|
+
studentCount: cls._count.students,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
membership,
|
|
320
|
+
teachingClasses: teachingClasses.map(toClassSummary),
|
|
321
|
+
enrolledClasses: enrolledClasses.map(toClassSummary),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function bulkUpdateMemberRoles(
|
|
326
|
+
userId: string,
|
|
327
|
+
input: {
|
|
328
|
+
schoolId: string;
|
|
329
|
+
memberUserIds: string[];
|
|
330
|
+
role: SchoolMembershipRole;
|
|
331
|
+
}
|
|
332
|
+
) {
|
|
333
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
334
|
+
const memberships = await listActiveSchoolMemberships(
|
|
335
|
+
input.schoolId,
|
|
336
|
+
input.memberUserIds
|
|
337
|
+
);
|
|
338
|
+
if (memberships.length === 0) {
|
|
339
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "No members found" });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (input.role !== "ADMIN") {
|
|
343
|
+
const demotedAdmins = memberships.filter((m) => m.role === "ADMIN").length;
|
|
344
|
+
if (demotedAdmins > 0) {
|
|
345
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
346
|
+
if (adminCount - demotedAdmins < 1) {
|
|
347
|
+
throw new TRPCError({
|
|
348
|
+
code: "BAD_REQUEST",
|
|
349
|
+
message: "A school must have at least one active admin",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await prisma.schoolMembership.updateMany({
|
|
356
|
+
where: {
|
|
357
|
+
schoolId: input.schoolId,
|
|
358
|
+
userId: { in: memberships.map((m) => m.userId) },
|
|
359
|
+
status: "ACTIVE",
|
|
360
|
+
},
|
|
361
|
+
data: { role: input.role },
|
|
362
|
+
});
|
|
363
|
+
return { updatedCount: memberships.length };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function bulkRemoveMembers(
|
|
367
|
+
userId: string,
|
|
368
|
+
input: { schoolId: string; memberUserIds: string[] }
|
|
369
|
+
) {
|
|
370
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
371
|
+
const targetIds = input.memberUserIds.filter((id) => id !== userId);
|
|
372
|
+
const memberships = await listActiveSchoolMemberships(
|
|
373
|
+
input.schoolId,
|
|
374
|
+
targetIds
|
|
375
|
+
);
|
|
376
|
+
if (memberships.length === 0) {
|
|
377
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "No members found" });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const removedAdmins = memberships.filter((m) => m.role === "ADMIN").length;
|
|
381
|
+
if (removedAdmins > 0) {
|
|
382
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
383
|
+
if (adminCount - removedAdmins < 1) {
|
|
384
|
+
throw new TRPCError({
|
|
385
|
+
code: "BAD_REQUEST",
|
|
386
|
+
message: "A school must have at least one active admin",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const membership of memberships) {
|
|
392
|
+
await removeSchoolMembershipPreservingContent(
|
|
393
|
+
input.schoolId,
|
|
394
|
+
membership.userId
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return { removedCount: memberships.length };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function deleteSchoolClass(
|
|
401
|
+
userId: string,
|
|
402
|
+
input: { schoolId: string; classId: string }
|
|
403
|
+
) {
|
|
404
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
405
|
+
const cls = await findSchoolClass(input.schoolId, input.classId);
|
|
406
|
+
if (!cls) {
|
|
407
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
408
|
+
}
|
|
409
|
+
await deleteSchoolClassRecord(input.schoolId, input.classId);
|
|
410
|
+
return { deletedClass: { id: input.classId } };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function updateClassMembers(
|
|
414
|
+
userId: string,
|
|
415
|
+
input: {
|
|
416
|
+
schoolId: string;
|
|
417
|
+
classId: string;
|
|
418
|
+
addTeacherIds?: string[];
|
|
419
|
+
removeTeacherIds?: string[];
|
|
420
|
+
addStudentIds?: string[];
|
|
421
|
+
removeStudentIds?: string[];
|
|
422
|
+
}
|
|
423
|
+
) {
|
|
424
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
425
|
+
const cls = await findSchoolClass(input.schoolId, input.classId);
|
|
426
|
+
if (!cls) {
|
|
427
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const addTeacherIds = input.addTeacherIds ?? [];
|
|
431
|
+
const addStudentIds = input.addStudentIds ?? [];
|
|
432
|
+
const addedIds = [...addTeacherIds, ...addStudentIds];
|
|
433
|
+
if (addedIds.length > 0) {
|
|
434
|
+
const memberships = await listActiveSchoolMemberships(
|
|
435
|
+
input.schoolId,
|
|
436
|
+
addedIds
|
|
437
|
+
);
|
|
438
|
+
const activeIds = new Set(memberships.map((m) => m.userId));
|
|
439
|
+
if (addedIds.some((id) => !activeIds.has(id))) {
|
|
440
|
+
throw new TRPCError({
|
|
441
|
+
code: "BAD_REQUEST",
|
|
442
|
+
message: "All added users must be active members of this school",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const updated = await updateSchoolClassMembers(input.classId, {
|
|
448
|
+
addTeacherIds,
|
|
449
|
+
removeTeacherIds: input.removeTeacherIds ?? [],
|
|
450
|
+
addStudentIds,
|
|
451
|
+
removeStudentIds: input.removeStudentIds ?? [],
|
|
452
|
+
});
|
|
453
|
+
return { class: updated };
|
|
454
|
+
}
|
|
455
|
+
|
|
268
456
|
export async function removeMember(
|
|
269
457
|
userId: string,
|
|
270
458
|
input: { schoolId: string; memberUserId: string }
|
|
@@ -346,7 +534,7 @@ export async function searchMembers(
|
|
|
346
534
|
schoolId: string,
|
|
347
535
|
query: string,
|
|
348
536
|
limit = 10,
|
|
349
|
-
role?:
|
|
537
|
+
role?: SchoolMembershipRole
|
|
350
538
|
) {
|
|
351
539
|
await requireSchoolMember(schoolId, userId);
|
|
352
540
|
const trimmed = query.trim();
|
|
@@ -502,36 +690,41 @@ export async function getStudentTimeline(
|
|
|
502
690
|
},
|
|
503
691
|
orderBy: { dueDate: "asc" },
|
|
504
692
|
}),
|
|
505
|
-
prisma.
|
|
693
|
+
prisma.attendanceRecord.findMany({
|
|
506
694
|
where: {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
...(hasRange ? { date: range } : {}),
|
|
695
|
+
studentId: input.studentId,
|
|
696
|
+
attendance: {
|
|
697
|
+
class: { schoolId: input.schoolId },
|
|
698
|
+
...(hasRange ? { date: range } : {}),
|
|
699
|
+
},
|
|
513
700
|
},
|
|
514
701
|
select: {
|
|
515
702
|
id: true,
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
703
|
+
status: true,
|
|
704
|
+
note: true,
|
|
705
|
+
excusedByExcusalId: true,
|
|
706
|
+
attendance: {
|
|
707
|
+
select: {
|
|
708
|
+
id: true,
|
|
709
|
+
date: true,
|
|
710
|
+
class: { select: { id: true, name: true, color: true } },
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
excusedByExcusal: { select: { id: true, reason: true } },
|
|
521
714
|
},
|
|
522
|
-
orderBy: { date: "asc" },
|
|
715
|
+
orderBy: { attendance: { date: "asc" } },
|
|
523
716
|
}),
|
|
524
717
|
]);
|
|
525
718
|
|
|
526
719
|
const attendanceShaped = attendance.map((record) => ({
|
|
527
|
-
id: record.id,
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
720
|
+
id: record.attendance.id,
|
|
721
|
+
recordId: record.id,
|
|
722
|
+
date: record.attendance.date,
|
|
723
|
+
class: record.attendance.class,
|
|
724
|
+
status: record.status,
|
|
725
|
+
note: record.note,
|
|
726
|
+
lockedFromExcusal: Boolean(record.excusedByExcusalId),
|
|
727
|
+
reason: record.excusedByExcusal?.reason ?? null,
|
|
535
728
|
}));
|
|
536
729
|
|
|
537
730
|
return {
|
|
@@ -671,36 +864,41 @@ export async function getMemberTimeline(
|
|
|
671
864
|
},
|
|
672
865
|
orderBy: { dueDate: "asc" },
|
|
673
866
|
}),
|
|
674
|
-
prisma.
|
|
867
|
+
prisma.attendanceRecord.findMany({
|
|
675
868
|
where: {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
...(hasRange ? { date: range } : {}),
|
|
869
|
+
studentId: input.memberId,
|
|
870
|
+
attendance: {
|
|
871
|
+
class: { schoolId: input.schoolId },
|
|
872
|
+
...(hasRange ? { date: range } : {}),
|
|
873
|
+
},
|
|
682
874
|
},
|
|
683
875
|
select: {
|
|
684
876
|
id: true,
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
877
|
+
status: true,
|
|
878
|
+
note: true,
|
|
879
|
+
excusedByExcusalId: true,
|
|
880
|
+
attendance: {
|
|
881
|
+
select: {
|
|
882
|
+
id: true,
|
|
883
|
+
date: true,
|
|
884
|
+
class: { select: { id: true, name: true, color: true } },
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
excusedByExcusal: { select: { id: true, reason: true } },
|
|
690
888
|
},
|
|
691
|
-
orderBy: { date: "asc" },
|
|
889
|
+
orderBy: { attendance: { date: "asc" } },
|
|
692
890
|
}),
|
|
693
891
|
]);
|
|
694
892
|
|
|
695
893
|
const attendanceShaped = attendance.map((record) => ({
|
|
696
|
-
id: record.id,
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
894
|
+
id: record.attendance.id,
|
|
895
|
+
recordId: record.id,
|
|
896
|
+
date: record.attendance.date,
|
|
897
|
+
class: record.attendance.class,
|
|
898
|
+
status: record.status,
|
|
899
|
+
note: record.note,
|
|
900
|
+
lockedFromExcusal: Boolean(record.excusedByExcusalId),
|
|
901
|
+
reason: record.excusedByExcusal?.reason ?? null,
|
|
704
902
|
}));
|
|
705
903
|
|
|
706
904
|
return {
|
|
@@ -0,0 +1,273 @@
|
|
|
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 { createUser, findUserByEmail, findUserByUsername } from "../models/auth.js";
|
|
15
|
+
import { findActiveSchoolAdminMembership, upsertSchoolMembership } from "../models/school.js";
|
|
16
|
+
import { upsertUserProfile } from "../models/user.js";
|
|
17
|
+
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
18
|
+
|
|
19
|
+
export type StudentCsvRowResult =
|
|
20
|
+
| {
|
|
21
|
+
ok: true;
|
|
22
|
+
row: number;
|
|
23
|
+
userId: string;
|
|
24
|
+
username: string;
|
|
25
|
+
email: string;
|
|
26
|
+
password: string;
|
|
27
|
+
displayName: string | null;
|
|
28
|
+
classId: string | null;
|
|
29
|
+
generatedUsername: boolean;
|
|
30
|
+
generatedPassword: boolean;
|
|
31
|
+
placeholderEmail: boolean;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
ok: false;
|
|
35
|
+
row: number;
|
|
36
|
+
error: string;
|
|
37
|
+
raw?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function parseCsvLine(line: string): string[] {
|
|
41
|
+
const cells: string[] = [];
|
|
42
|
+
let current = "";
|
|
43
|
+
let inQuotes = false;
|
|
44
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
45
|
+
const ch = line[i];
|
|
46
|
+
if (inQuotes) {
|
|
47
|
+
if (ch === '"') {
|
|
48
|
+
if (line[i + 1] === '"') {
|
|
49
|
+
current += '"';
|
|
50
|
+
i += 1;
|
|
51
|
+
} else {
|
|
52
|
+
inQuotes = false;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
current += ch;
|
|
56
|
+
}
|
|
57
|
+
} else if (ch === '"') {
|
|
58
|
+
inQuotes = true;
|
|
59
|
+
} else if (ch === ",") {
|
|
60
|
+
cells.push(current.trim());
|
|
61
|
+
current = "";
|
|
62
|
+
} else {
|
|
63
|
+
current += ch;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
cells.push(current.trim());
|
|
67
|
+
return cells;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeHeader(h: string) {
|
|
71
|
+
return h.trim().toLowerCase().replace(/[\s_]+/g, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function generatePassword(length = 12) {
|
|
75
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
|
76
|
+
const bytes = randomBytes(length);
|
|
77
|
+
let out = "";
|
|
78
|
+
for (let i = 0; i < length; i += 1) {
|
|
79
|
+
out += alphabet[bytes[i]! % alphabet.length];
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function placeholderEmail(username: string) {
|
|
85
|
+
const safe = username.toLowerCase().replace(/[^a-z0-9._-]/g, "");
|
|
86
|
+
return `${safe || "student"}.${randomBytes(3).toString("hex")}@noemail.studious.local`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseStudentCsv(csvText: string): {
|
|
90
|
+
headers: string[];
|
|
91
|
+
rows: Record<string, string>[];
|
|
92
|
+
} {
|
|
93
|
+
const lines = csvText
|
|
94
|
+
.replace(/^\uFEFF/, "")
|
|
95
|
+
.split(/\r?\n/)
|
|
96
|
+
.map((l) => l.trim())
|
|
97
|
+
.filter((l) => l.length > 0);
|
|
98
|
+
if (lines.length < 2) {
|
|
99
|
+
throw new TRPCError({
|
|
100
|
+
code: "BAD_REQUEST",
|
|
101
|
+
message: "CSV must include a header row and at least one data row",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const headers = parseCsvLine(lines[0]!).map(normalizeHeader);
|
|
105
|
+
const rows = lines.slice(1).map((line) => {
|
|
106
|
+
const cells = parseCsvLine(line);
|
|
107
|
+
const row: Record<string, string> = {};
|
|
108
|
+
headers.forEach((h, i) => {
|
|
109
|
+
row[h] = cells[i] ?? "";
|
|
110
|
+
});
|
|
111
|
+
return row;
|
|
112
|
+
});
|
|
113
|
+
return { headers, rows };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function importStudentsCsv(
|
|
117
|
+
userId: string,
|
|
118
|
+
input: { schoolId: string; csvText: string; dryRun?: boolean }
|
|
119
|
+
) {
|
|
120
|
+
const membership = await findActiveSchoolAdminMembership(input.schoolId, userId);
|
|
121
|
+
if (!membership) {
|
|
122
|
+
throw new TRPCError({
|
|
123
|
+
code: "FORBIDDEN",
|
|
124
|
+
message: "Only school admins can perform this action",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { rows } = parseStudentCsv(input.csvText);
|
|
129
|
+
if (rows.length > 500) {
|
|
130
|
+
throw new TRPCError({
|
|
131
|
+
code: "BAD_REQUEST",
|
|
132
|
+
message: "CSV import is limited to 500 rows per request",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const results: StudentCsvRowResult[] = [];
|
|
137
|
+
let created = 0;
|
|
138
|
+
let failed = 0;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
141
|
+
const rowNum = i + 2; // 1-indexed + header
|
|
142
|
+
const row = rows[i]!;
|
|
143
|
+
const displayName =
|
|
144
|
+
row.displayname || row.name || row.fullname || row.fullname || "";
|
|
145
|
+
const requestedUsername = row.username || "";
|
|
146
|
+
const requestedEmail = (row.email || "").toLowerCase();
|
|
147
|
+
const requestedPassword = row.password || "";
|
|
148
|
+
const classId = row.classid || row.class || "";
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
if (!displayName && !requestedUsername && !requestedEmail) {
|
|
152
|
+
throw new Error("Row needs displayName, username, or email");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let username = requestedUsername;
|
|
156
|
+
let generatedUsername = false;
|
|
157
|
+
if (!username) {
|
|
158
|
+
username = await generateUniqueUsername(async (candidate) => {
|
|
159
|
+
const existing = await findUserByUsername(candidate);
|
|
160
|
+
return Boolean(existing);
|
|
161
|
+
});
|
|
162
|
+
generatedUsername = true;
|
|
163
|
+
} else {
|
|
164
|
+
const taken = await findUserByUsername(username);
|
|
165
|
+
if (taken) {
|
|
166
|
+
throw new Error(`Username "${username}" already taken`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let email = requestedEmail;
|
|
171
|
+
let placeholderEmailUsed = false;
|
|
172
|
+
if (!email) {
|
|
173
|
+
email = placeholderEmail(username);
|
|
174
|
+
placeholderEmailUsed = true;
|
|
175
|
+
} else {
|
|
176
|
+
const existing = await findUserByEmail(email);
|
|
177
|
+
if (existing) {
|
|
178
|
+
throw new Error(`Email "${email}" already in use`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let password = requestedPassword;
|
|
183
|
+
let generatedPassword = false;
|
|
184
|
+
if (!password) {
|
|
185
|
+
password = generatePassword();
|
|
186
|
+
generatedPassword = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (classId) {
|
|
190
|
+
const cls = await prisma.class.findFirst({
|
|
191
|
+
where: { id: classId, schoolId: input.schoolId },
|
|
192
|
+
select: { id: true },
|
|
193
|
+
});
|
|
194
|
+
if (!cls) {
|
|
195
|
+
throw new Error(`classId "${classId}" not found in this school`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (input.dryRun) {
|
|
200
|
+
results.push({
|
|
201
|
+
ok: true,
|
|
202
|
+
row: rowNum,
|
|
203
|
+
userId: "dry-run",
|
|
204
|
+
username,
|
|
205
|
+
email,
|
|
206
|
+
password,
|
|
207
|
+
displayName: displayName || null,
|
|
208
|
+
classId: classId || null,
|
|
209
|
+
generatedUsername,
|
|
210
|
+
generatedPassword,
|
|
211
|
+
placeholderEmail: placeholderEmailUsed,
|
|
212
|
+
});
|
|
213
|
+
created += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const hashed = await hash(password, 10);
|
|
218
|
+
const user = await createUser({
|
|
219
|
+
username,
|
|
220
|
+
email,
|
|
221
|
+
password: hashed,
|
|
222
|
+
verified: true,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (displayName) {
|
|
226
|
+
await upsertUserProfile(user.id, { displayName });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await upsertSchoolMembership({
|
|
230
|
+
schoolId: input.schoolId,
|
|
231
|
+
userId: user.id,
|
|
232
|
+
role: "STUDENT",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (classId) {
|
|
236
|
+
await prisma.class.update({
|
|
237
|
+
where: { id: classId },
|
|
238
|
+
data: { students: { connect: { id: user.id } } },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
results.push({
|
|
243
|
+
ok: true,
|
|
244
|
+
row: rowNum,
|
|
245
|
+
userId: user.id,
|
|
246
|
+
username: user.username,
|
|
247
|
+
email: user.email,
|
|
248
|
+
password,
|
|
249
|
+
displayName: displayName || null,
|
|
250
|
+
classId: classId || null,
|
|
251
|
+
generatedUsername,
|
|
252
|
+
generatedPassword,
|
|
253
|
+
placeholderEmail: placeholderEmailUsed,
|
|
254
|
+
});
|
|
255
|
+
created += 1;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
failed += 1;
|
|
258
|
+
results.push({
|
|
259
|
+
ok: false,
|
|
260
|
+
row: rowNum,
|
|
261
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
dryRun: Boolean(input.dryRun),
|
|
268
|
+
created,
|
|
269
|
+
failed,
|
|
270
|
+
total: rows.length,
|
|
271
|
+
results,
|
|
272
|
+
};
|
|
273
|
+
}
|