@studious-lms/server 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1 -1
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +4 -4
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +4 -4
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -14
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/marketplace.d.ts +2 -0
- package/dist/models/marketplace.d.ts.map +1 -1
- package/dist/models/marketplace.js +3 -2
- package/dist/models/marketplace.js.map +1 -1
- package/dist/models/moduleItem.d.ts +5 -1
- package/dist/models/moduleItem.d.ts.map +1 -1
- package/dist/models/moduleItem.js +19 -4
- package/dist/models/moduleItem.js.map +1 -1
- package/dist/routers/_app.d.ts +8 -6
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/fees.d.ts +1 -1
- package/dist/routers/moduleItem.d.ts.map +1 -1
- package/dist/routers/moduleItem.js +7 -6
- package/dist/routers/moduleItem.js.map +1 -1
- package/dist/routers/parent.d.ts +2 -1
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/school.d.ts +1 -1
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +14 -4
- package/dist/routers/school.js.map +1 -1
- package/dist/services/academicCalendar.d.ts.map +1 -1
- package/dist/services/academicCalendar.js +30 -2
- package/dist/services/academicCalendar.js.map +1 -1
- package/dist/services/attendance.d.ts +6 -2
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +8 -4
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/class.d.ts +14 -9
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +4 -4
- package/dist/services/class.js.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +24 -4
- package/dist/services/fees.js.map +1 -1
- package/dist/services/feesAuthz.d.ts +2 -5
- package/dist/services/feesAuthz.d.ts.map +1 -1
- package/dist/services/feesAuthz.js +5 -8
- package/dist/services/feesAuthz.js.map +1 -1
- package/dist/services/marketplace.d.ts.map +1 -1
- package/dist/services/marketplace.js +8 -5
- package/dist/services/marketplace.js.map +1 -1
- package/dist/services/moduleItem.d.ts.map +1 -1
- package/dist/services/moduleItem.js +23 -7
- package/dist/services/moduleItem.js.map +1 -1
- package/dist/services/parentPortal.d.ts +3 -5
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +29 -43
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +37 -13
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts.map +1 -1
- package/dist/services/studentImport.js +38 -25
- package/dist/services/studentImport.js.map +1 -1
- package/dist/services/transcript.d.ts +2 -3
- package/dist/services/transcript.d.ts.map +1 -1
- package/dist/services/transcript.js +13 -25
- package/dist/services/transcript.js.map +1 -1
- package/package.json +1 -1
- package/scripts/import-nepal-cdc-curriculum.ts +29 -6
- package/src/models/attendance.ts +10 -12
- package/src/models/marketplace.ts +3 -0
- package/src/models/moduleItem.ts +20 -2
- package/src/routers/moduleItem.ts +6 -4
- package/src/routers/school.ts +16 -6
- package/src/services/academicCalendar.ts +28 -0
- package/src/services/attendance.ts +7 -3
- package/src/services/class.ts +2 -2
- package/src/services/fees.ts +25 -2
- package/src/services/feesAuthz.ts +3 -7
- package/src/services/marketplace.ts +8 -3
- package/src/services/moduleItem.ts +28 -4
- package/src/services/parentPortal.ts +26 -39
- package/src/services/school.ts +37 -12
- package/src/services/studentImport.ts +37 -25
- package/src/services/transcript.ts +11 -23
package/src/services/fees.ts
CHANGED
|
@@ -39,6 +39,20 @@ async function requireAdmin(schoolId: string, userId: string) {
|
|
|
39
39
|
return membership;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
async function assertActiveSchoolStudents(
|
|
43
|
+
schoolId: string,
|
|
44
|
+
studentIds: string[]
|
|
45
|
+
) {
|
|
46
|
+
const activeIds = new Set(await listActiveStudentIds(schoolId));
|
|
47
|
+
const invalid = studentIds.filter((id) => !activeIds.has(id));
|
|
48
|
+
if (invalid.length > 0) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "BAD_REQUEST",
|
|
51
|
+
message: "One or more students are not active members of this school",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function assertMonth(month: number) {
|
|
43
57
|
if (month < 1 || month > 12) {
|
|
44
58
|
throw new TRPCError({
|
|
@@ -117,6 +131,7 @@ export async function createPlan(
|
|
|
117
131
|
amountNpr: input.amountNpr,
|
|
118
132
|
});
|
|
119
133
|
if (input.studentIds?.length) {
|
|
134
|
+
await assertActiveSchoolStudents(input.schoolId, input.studentIds);
|
|
120
135
|
const updated = await setFeePlanEnrollments(plan.id, input.studentIds);
|
|
121
136
|
return { plan: updated };
|
|
122
137
|
}
|
|
@@ -162,6 +177,7 @@ export async function setPlanStudents(
|
|
|
162
177
|
throw new TRPCError({ code: "NOT_FOUND", message: "Fee plan not found" });
|
|
163
178
|
}
|
|
164
179
|
await requireAdmin(existing.schoolId, userId);
|
|
180
|
+
await assertActiveSchoolStudents(existing.schoolId, input.studentIds);
|
|
165
181
|
const plan = await setFeePlanEnrollments(input.planId, input.studentIds);
|
|
166
182
|
return { plan };
|
|
167
183
|
}
|
|
@@ -192,7 +208,9 @@ export async function generateMonthlyInvoices(
|
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
let studentIds = input.studentIds;
|
|
195
|
-
if (
|
|
211
|
+
if (studentIds?.length) {
|
|
212
|
+
await assertActiveSchoolStudents(input.schoolId, studentIds);
|
|
213
|
+
} else {
|
|
196
214
|
const enrolled = await listPlanEnrollmentIds(plan.id);
|
|
197
215
|
studentIds = enrolled.length
|
|
198
216
|
? enrolled
|
|
@@ -410,7 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
|
|
|
410
428
|
},
|
|
411
429
|
{
|
|
412
430
|
label: "Paid at",
|
|
413
|
-
value:
|
|
431
|
+
value: new Intl.DateTimeFormat("en-CA", {
|
|
432
|
+
timeZone: "Asia/Kathmandu",
|
|
433
|
+
year: "numeric",
|
|
434
|
+
month: "2-digit",
|
|
435
|
+
day: "2-digit",
|
|
436
|
+
}).format(payment.paidAt),
|
|
414
437
|
},
|
|
415
438
|
],
|
|
416
439
|
footerNote: "Manual ledger receipt — gateway integration deferred.",
|
|
@@ -54,10 +54,7 @@ export function canManageFees(role: SchoolMembershipRole) {
|
|
|
54
54
|
return FEE_WRITE_ROLES.includes(role);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* Placeholder procedure surface until A2 fee models exist.
|
|
59
|
-
* Returns capability flags so the client can show/hide fee UI.
|
|
60
|
-
*/
|
|
57
|
+
/** Returns capability flags so the client can show/hide fee UI. */
|
|
61
58
|
export async function getFeeCapabilities(userId: string, schoolId: string) {
|
|
62
59
|
const membership = await findActiveSchoolMembership(schoolId, userId);
|
|
63
60
|
if (!membership) {
|
|
@@ -70,8 +67,7 @@ export async function getFeeCapabilities(userId: string, schoolId: string) {
|
|
|
70
67
|
role: membership.role,
|
|
71
68
|
canManageFees: canManageFees(membership.role),
|
|
72
69
|
canViewFees: FEE_READ_ROLES.includes(membership.role),
|
|
73
|
-
feesImplemented:
|
|
74
|
-
message:
|
|
75
|
-
"Fee models are not implemented on this branch yet (owned by A2). Authz stubs are ready.",
|
|
70
|
+
feesImplemented: true as const,
|
|
71
|
+
message: "Fee features are available.",
|
|
76
72
|
};
|
|
77
73
|
}
|
|
@@ -146,14 +146,19 @@ export async function listMarketplace(
|
|
|
146
146
|
}
|
|
147
147
|
) {
|
|
148
148
|
const memberships = await listUserSchoolMemberships(userId);
|
|
149
|
+
if (
|
|
150
|
+
input.schoolId &&
|
|
151
|
+
!memberships.some((m) => m.schoolId === input.schoolId)
|
|
152
|
+
) {
|
|
153
|
+
return { items: [], nextCursor: undefined };
|
|
154
|
+
}
|
|
149
155
|
const schoolIds = input.schoolId
|
|
150
|
-
?
|
|
151
|
-
? [input.schoolId]
|
|
152
|
-
: []
|
|
156
|
+
? [input.schoolId]
|
|
153
157
|
: memberships.map((m) => m.schoolId);
|
|
154
158
|
|
|
155
159
|
return searchMarketplaceListings({
|
|
156
160
|
schoolIds,
|
|
161
|
+
scopeSchoolId: input.schoolId,
|
|
157
162
|
type: input.type,
|
|
158
163
|
search: input.search,
|
|
159
164
|
tags: input.tags,
|
|
@@ -7,6 +7,7 @@ import { prisma } from "../lib/prisma.js";
|
|
|
7
7
|
import {
|
|
8
8
|
attachFilesToModuleItem,
|
|
9
9
|
createModuleItem,
|
|
10
|
+
findAccessibleFilesByIds,
|
|
10
11
|
deleteModuleItem,
|
|
11
12
|
findModuleItemByIdForMember,
|
|
12
13
|
findModuleItemByIdForTeacher,
|
|
@@ -33,6 +34,21 @@ function assertVideoFiles(
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
async function resolveExistingFiles(
|
|
38
|
+
type: ModuleItemType,
|
|
39
|
+
existingFileIds: string[],
|
|
40
|
+
userId: string
|
|
41
|
+
) {
|
|
42
|
+
const accessible = await findAccessibleFilesByIds(existingFileIds, userId);
|
|
43
|
+
if (accessible.length !== new Set(existingFileIds).size) {
|
|
44
|
+
throw new TRPCError({
|
|
45
|
+
code: "FORBIDDEN",
|
|
46
|
+
message: "One or more files are not accessible",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
assertVideoFiles(type, accessible);
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
async function assertTeacherOwnsSection(sectionId: string, userId: string) {
|
|
37
53
|
const section = await prisma.section.findFirst({
|
|
38
54
|
where: {
|
|
@@ -142,6 +158,9 @@ export async function createModuleItemRecord(
|
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
assertVideoFiles(input.type, input.files);
|
|
161
|
+
if (input.existingFileIds?.length) {
|
|
162
|
+
await resolveExistingFiles(input.type, input.existingFileIds, userId);
|
|
163
|
+
}
|
|
145
164
|
|
|
146
165
|
const maxOrder = await prisma.moduleItem.aggregate({
|
|
147
166
|
where: { sectionId: input.sectionId },
|
|
@@ -159,7 +178,7 @@ export async function createModuleItemRecord(
|
|
|
159
178
|
});
|
|
160
179
|
|
|
161
180
|
if (input.existingFileIds?.length) {
|
|
162
|
-
await attachFilesToModuleItem(created.id, input.existingFileIds);
|
|
181
|
+
await attachFilesToModuleItem(created.id, input.existingFileIds, userId);
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
let uploadFiles: Awaited<ReturnType<typeof createDirectUploadFiles>> = [];
|
|
@@ -173,7 +192,8 @@ export async function createModuleItemRecord(
|
|
|
173
192
|
);
|
|
174
193
|
await attachFilesToModuleItem(
|
|
175
194
|
created.id,
|
|
176
|
-
uploadFiles.map((f) => f.id)
|
|
195
|
+
uploadFiles.map((f) => f.id),
|
|
196
|
+
userId
|
|
177
197
|
);
|
|
178
198
|
}
|
|
179
199
|
|
|
@@ -205,6 +225,9 @@ export async function updateModuleItemRecord(
|
|
|
205
225
|
|
|
206
226
|
const nextType = input.type ?? existing.type;
|
|
207
227
|
assertVideoFiles(nextType, input.files);
|
|
228
|
+
if (input.existingFileIds?.length) {
|
|
229
|
+
await resolveExistingFiles(nextType, input.existingFileIds, userId);
|
|
230
|
+
}
|
|
208
231
|
|
|
209
232
|
if (input.sectionId && input.sectionId !== existing.sectionId) {
|
|
210
233
|
await assertTeacherOwnsSection(input.sectionId, userId);
|
|
@@ -220,7 +243,7 @@ export async function updateModuleItemRecord(
|
|
|
220
243
|
});
|
|
221
244
|
|
|
222
245
|
if (input.existingFileIds?.length) {
|
|
223
|
-
await attachFilesToModuleItem(input.id, input.existingFileIds);
|
|
246
|
+
await attachFilesToModuleItem(input.id, input.existingFileIds, userId);
|
|
224
247
|
}
|
|
225
248
|
|
|
226
249
|
let uploadFiles: Awaited<ReturnType<typeof createDirectUploadFiles>> = [];
|
|
@@ -234,7 +257,8 @@ export async function updateModuleItemRecord(
|
|
|
234
257
|
);
|
|
235
258
|
await attachFilesToModuleItem(
|
|
236
259
|
input.id,
|
|
237
|
-
uploadFiles.map((f) => f.id)
|
|
260
|
+
uploadFiles.map((f) => f.id),
|
|
261
|
+
userId
|
|
238
262
|
);
|
|
239
263
|
}
|
|
240
264
|
|
|
@@ -208,10 +208,7 @@ async function assertParentOfStudent(
|
|
|
208
208
|
return link;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
/**
|
|
212
|
-
* Read-only attendance stub for a linked child (aggregates class attendance M2M).
|
|
213
|
-
* Does not touch A1 AttendanceRecord refactor.
|
|
214
|
-
*/
|
|
211
|
+
/** Read-only attendance view for a linked child (reads AttendanceRecord). */
|
|
215
212
|
export async function getChildAttendance(
|
|
216
213
|
userId: string,
|
|
217
214
|
input: {
|
|
@@ -234,58 +231,48 @@ export async function getChildAttendance(
|
|
|
234
231
|
});
|
|
235
232
|
const classIds = classes.map((c) => c.id);
|
|
236
233
|
|
|
237
|
-
const
|
|
234
|
+
const attendanceRecords = await prisma.attendanceRecord.findMany({
|
|
238
235
|
where: {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
{ absent: { some: { id: input.studentUserId } } },
|
|
252
|
-
],
|
|
236
|
+
studentId: input.studentUserId,
|
|
237
|
+
attendance: {
|
|
238
|
+
classId: { in: classIds },
|
|
239
|
+
...(input.from || input.to
|
|
240
|
+
? {
|
|
241
|
+
date: {
|
|
242
|
+
...(input.from ? { gte: input.from } : {}),
|
|
243
|
+
...(input.to ? { lte: input.to } : {}),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: {}),
|
|
247
|
+
},
|
|
253
248
|
},
|
|
254
249
|
select: {
|
|
255
|
-
|
|
256
|
-
date: true,
|
|
257
|
-
classId: true,
|
|
258
|
-
present: { where: { id: input.studentUserId }, select: { id: true } },
|
|
259
|
-
late: { where: { id: input.studentUserId }, select: { id: true } },
|
|
260
|
-
absent: { where: { id: input.studentUserId }, select: { id: true } },
|
|
250
|
+
status: true,
|
|
251
|
+
attendance: { select: { id: true, date: true, classId: true } },
|
|
261
252
|
},
|
|
262
|
-
orderBy: { date: "desc" },
|
|
253
|
+
orderBy: { attendance: { date: "desc" } },
|
|
263
254
|
take: 200,
|
|
264
255
|
});
|
|
265
256
|
|
|
266
257
|
const classMap = new Map(classes.map((c) => [c.id, c]));
|
|
267
|
-
const records =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
classId: row.classId,
|
|
275
|
-
class: classMap.get(row.classId) ?? null,
|
|
276
|
-
status,
|
|
277
|
-
};
|
|
278
|
-
});
|
|
258
|
+
const records = attendanceRecords.map((row) => ({
|
|
259
|
+
attendanceId: row.attendance.id,
|
|
260
|
+
date: row.attendance.date,
|
|
261
|
+
classId: row.attendance.classId,
|
|
262
|
+
class: classMap.get(row.attendance.classId) ?? null,
|
|
263
|
+
status: row.status,
|
|
264
|
+
}));
|
|
279
265
|
|
|
280
266
|
const summary = records.reduce(
|
|
281
267
|
(acc, r) => {
|
|
282
268
|
acc.total += 1;
|
|
283
269
|
if (r.status === "PRESENT") acc.present += 1;
|
|
284
270
|
else if (r.status === "LATE") acc.late += 1;
|
|
271
|
+
else if (r.status === "EXCUSED") acc.excused += 1;
|
|
285
272
|
else acc.absent += 1;
|
|
286
273
|
return acc;
|
|
287
274
|
},
|
|
288
|
-
{ present: 0, late: 0, absent: 0, total: 0 }
|
|
275
|
+
{ present: 0, late: 0, absent: 0, excused: 0, total: 0 }
|
|
289
276
|
);
|
|
290
277
|
|
|
291
278
|
return { summary, records };
|
package/src/services/school.ts
CHANGED
|
@@ -133,6 +133,28 @@ export async function updateSchoolDetails(
|
|
|
133
133
|
}
|
|
134
134
|
) {
|
|
135
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
|
+
}
|
|
136
158
|
const updated = await updateSchoolRecord(input.schoolId, {
|
|
137
159
|
name: input.name,
|
|
138
160
|
description: input.description,
|
|
@@ -628,17 +650,18 @@ export async function getStudentTimeline(
|
|
|
628
650
|
});
|
|
629
651
|
}
|
|
630
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);
|
|
631
656
|
const range = {
|
|
632
|
-
|
|
657
|
+
gte: input.from ?? defaultFrom,
|
|
633
658
|
...(input.to ? { lte: input.to } : {}),
|
|
634
659
|
};
|
|
635
|
-
const hasRange = Object.keys(range).length > 0;
|
|
636
|
-
|
|
637
660
|
const [events, assignments, attendance] = await Promise.all([
|
|
638
661
|
prisma.event.findMany({
|
|
639
662
|
where: {
|
|
640
663
|
userId: input.studentId,
|
|
641
|
-
|
|
664
|
+
startTime: range,
|
|
642
665
|
},
|
|
643
666
|
select: {
|
|
644
667
|
id: true,
|
|
@@ -658,7 +681,7 @@ export async function getStudentTimeline(
|
|
|
658
681
|
students: { some: { id: input.studentId } },
|
|
659
682
|
},
|
|
660
683
|
template: false,
|
|
661
|
-
|
|
684
|
+
dueDate: range,
|
|
662
685
|
},
|
|
663
686
|
select: {
|
|
664
687
|
id: true,
|
|
@@ -695,7 +718,7 @@ export async function getStudentTimeline(
|
|
|
695
718
|
studentId: input.studentId,
|
|
696
719
|
attendance: {
|
|
697
720
|
class: { schoolId: input.schoolId },
|
|
698
|
-
|
|
721
|
+
date: range,
|
|
699
722
|
},
|
|
700
723
|
},
|
|
701
724
|
select: {
|
|
@@ -786,11 +809,13 @@ export async function getMemberTimeline(
|
|
|
786
809
|
});
|
|
787
810
|
}
|
|
788
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);
|
|
789
815
|
const range = {
|
|
790
|
-
|
|
816
|
+
gte: input.from ?? defaultFrom,
|
|
791
817
|
...(input.to ? { lte: input.to } : {}),
|
|
792
818
|
};
|
|
793
|
-
const hasRange = Object.keys(range).length > 0;
|
|
794
819
|
|
|
795
820
|
if (membership.role === "STUDENT") {
|
|
796
821
|
if (!callerIsAdmin) {
|
|
@@ -814,7 +839,7 @@ export async function getMemberTimeline(
|
|
|
814
839
|
prisma.event.findMany({
|
|
815
840
|
where: {
|
|
816
841
|
userId: input.memberId,
|
|
817
|
-
|
|
842
|
+
startTime: range,
|
|
818
843
|
},
|
|
819
844
|
select: {
|
|
820
845
|
id: true,
|
|
@@ -832,7 +857,7 @@ export async function getMemberTimeline(
|
|
|
832
857
|
where: {
|
|
833
858
|
class: { students: { some: { id: input.memberId } } },
|
|
834
859
|
template: false,
|
|
835
|
-
|
|
860
|
+
dueDate: range,
|
|
836
861
|
},
|
|
837
862
|
select: {
|
|
838
863
|
id: true,
|
|
@@ -869,7 +894,7 @@ export async function getMemberTimeline(
|
|
|
869
894
|
studentId: input.memberId,
|
|
870
895
|
attendance: {
|
|
871
896
|
class: { schoolId: input.schoolId },
|
|
872
|
-
|
|
897
|
+
date: range,
|
|
873
898
|
},
|
|
874
899
|
},
|
|
875
900
|
select: {
|
|
@@ -925,7 +950,7 @@ export async function getMemberTimeline(
|
|
|
925
950
|
{ userId: input.memberId },
|
|
926
951
|
{ class: { teachers: { some: { id: input.memberId } } } },
|
|
927
952
|
],
|
|
928
|
-
|
|
953
|
+
startTime: range,
|
|
929
954
|
},
|
|
930
955
|
select: {
|
|
931
956
|
id: true,
|
|
@@ -11,9 +11,8 @@ import { randomBytes } from "crypto";
|
|
|
11
11
|
import { hash } from "bcryptjs";
|
|
12
12
|
import { TRPCError } from "@trpc/server";
|
|
13
13
|
import { prisma } from "../lib/prisma.js";
|
|
14
|
-
import {
|
|
15
|
-
import { findActiveSchoolAdminMembership
|
|
16
|
-
import { upsertUserProfile } from "../models/user.js";
|
|
14
|
+
import { findUserByEmail, findUserByUsername } from "../models/auth.js";
|
|
15
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
17
16
|
import { generateUniqueUsername } from "../utils/generateUsername.js";
|
|
18
17
|
|
|
19
18
|
export type StudentCsvRowResult =
|
|
@@ -140,8 +139,7 @@ export async function importStudentsCsv(
|
|
|
140
139
|
for (let i = 0; i < rows.length; i += 1) {
|
|
141
140
|
const rowNum = i + 2; // 1-indexed + header
|
|
142
141
|
const row = rows[i]!;
|
|
143
|
-
const displayName =
|
|
144
|
-
row.displayname || row.name || row.fullname || row.fullname || "";
|
|
142
|
+
const displayName = row.displayname || row.name || row.fullname || "";
|
|
145
143
|
const requestedUsername = row.username || "";
|
|
146
144
|
const requestedEmail = (row.email || "").toLowerCase();
|
|
147
145
|
const requestedPassword = row.password || "";
|
|
@@ -215,29 +213,43 @@ export async function importStudentsCsv(
|
|
|
215
213
|
}
|
|
216
214
|
|
|
217
215
|
const hashed = await hash(password, 10);
|
|
218
|
-
const user = await
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
});
|
|
224
227
|
|
|
225
|
-
|
|
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
|
+
});
|
|
228
243
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
244
|
+
if (classId) {
|
|
245
|
+
await tx.class.update({
|
|
246
|
+
where: { id: classId },
|
|
247
|
+
data: { students: { connect: { id: createdUser.id } } },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
where: { id: classId },
|
|
238
|
-
data: { students: { connect: { id: user.id } } },
|
|
239
|
-
});
|
|
240
|
-
}
|
|
251
|
+
return createdUser;
|
|
252
|
+
});
|
|
241
253
|
|
|
242
254
|
results.push({
|
|
243
255
|
ok: true,
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* AcademicTerm (C1) is not modelled yet — accept `from`/`to` date range and/or
|
|
9
9
|
* a free-text `schoolYear` string for the report header.
|
|
10
10
|
*
|
|
11
|
-
* Attendance is read
|
|
12
|
-
*
|
|
13
|
-
* refactor — rate math lives in attendanceRate.ts.
|
|
11
|
+
* Attendance is read from AttendanceRecord (EXCUSED excluded from the rate
|
|
12
|
+
* denominator) — rate math lives in attendanceRate.ts.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import { TRPCError } from "@trpc/server";
|
|
@@ -93,30 +92,19 @@ async function fetchStudentAttendance(
|
|
|
93
92
|
};
|
|
94
93
|
const hasRange = Object.keys(range).length > 0;
|
|
95
94
|
|
|
96
|
-
//
|
|
97
|
-
|
|
95
|
+
// Read AttendanceRecord so EXCUSED records are excluded from the rate
|
|
96
|
+
// denominator (computeAttendanceRate ignores non-PRESENT/LATE/ABSENT).
|
|
97
|
+
const records = await prisma.attendanceRecord.findMany({
|
|
98
98
|
where: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 } },
|
|
99
|
+
studentId,
|
|
100
|
+
status: { in: ["PRESENT", "LATE", "ABSENT"] },
|
|
101
|
+
...(hasRange ? { attendance: { date: range } } : {}),
|
|
111
102
|
},
|
|
103
|
+
select: { status: true },
|
|
112
104
|
});
|
|
113
105
|
|
|
114
|
-
return
|
|
115
|
-
status: record.
|
|
116
|
-
? ("PRESENT" as const)
|
|
117
|
-
: record.late.length
|
|
118
|
-
? ("LATE" as const)
|
|
119
|
-
: ("ABSENT" as const),
|
|
106
|
+
return records.map((record) => ({
|
|
107
|
+
status: record.status as "PRESENT" | "LATE" | "ABSENT",
|
|
120
108
|
}));
|
|
121
109
|
}
|
|
122
110
|
|