diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5aeb11..f2d3872 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -123,6 +123,12 @@ enum PreferredTeacherSource { REMS } +enum ReliefAssignmentStatus { + ACTIVE + CANCELLED_ASSIGNEE_ABSENT + CANCELLED_ADMIN +} + // ─── Multi-tenancy ────────────────────────────────────── model School { @@ -399,21 +405,22 @@ model AbsencePeriodRequirement { } model ReliefAssignment { - id String @id @default(cuid()) - absenceReportId String @map("sickReportId") + id String @id @default(cuid()) + absenceReportId String @map("sickReportId") timetableEntryId String reliefTeacherId String date DateTime - ackToken String? @unique // signed token for one-tap acknowledge + status ReliefAssignmentStatus @default(ACTIVE) + cancelledAt DateTime? + replacedById String? @unique + ackToken String? @unique acknowledgedAt DateTime? - // Phase 15 / D2 — who created this assignment? Null when system-created - // (seed, future cron paths). For multi-KP schools (Zhenghua), the - // dashboard reads this to show "Assigned by ___" so concurrent KPs can - // tell who took which row. assignedByUserId String? schoolId String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + replacedBy ReliefAssignment? @relation("ReliefChain", fields: [replacedById], references: [id], onDelete: SetNull) + replacesAssignment ReliefAssignment? @relation("ReliefChain") school School @relation(fields: [schoolId], references: [id], onDelete: Cascade) absenceReport AbsenceReport @relation(fields: [absenceReportId], references: [id], onDelete: Cascade) timetableEntry TimetableEntry @relation(fields: [timetableEntryId], references: [id], onDelete: Cascade) @@ -421,7 +428,8 @@ model ReliefAssignment { assignedBy User? @relation("ReliefAssignedBy", fields: [assignedByUserId], references: [id], onDelete: SetNull) outboundMessages OutboundMessage[] - @@unique([timetableEntryId, date]) + @@index([timetableEntryId, date]) + @@index([status]) @@index([date]) @@index([reliefTeacherId, date]) @@index([absenceReportId]) diff --git a/prisma/seed.ts b/prisma/seed.ts index 9b8f3a2..785b19c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -714,6 +714,42 @@ async function main() { } } + // ── Cascading absence example: one cancelled-because-assignee-absent assignment ── + // Demonstrates the data shape when a relief teacher also falls sick. + if (reliefAssignmentCount > 0) { + const sampleActive = await prisma.reliefAssignment.findFirst({ + where: { schoolId, status: "ACTIVE" }, + select: { id: true, timetableEntryId: true, date: true, absenceReportId: true }, + }); + if (sampleActive) { + await prisma.reliefAssignment.update({ + where: { id: sampleActive.id }, + data: { status: "CANCELLED_ASSIGNEE_ABSENT", cancelledAt: new Date() }, + }); + const replacementCandidates = await prisma.teacher.findMany({ + where: { schoolId }, + select: { id: true }, + take: 5, + }); + if (replacementCandidates.length > 0) { + const replacement = pickRandom(replacementCandidates); + const replacedBy = await prisma.reliefAssignment.create({ + data: { + absenceReportId: sampleActive.absenceReportId, + timetableEntryId: sampleActive.timetableEntryId, + reliefTeacherId: replacement.id, + date: sampleActive.date, + schoolId, + }, + }); + await prisma.reliefAssignment.update({ + where: { id: sampleActive.id }, + data: { replacedById: replacedBy.id }, + }); + } + } + } + // ── Matching config ── await prisma.matchingConfig.create({ data: { diff --git a/src/app/api/[slug]/preferred-relief-assignments/route.ts b/src/app/api/[slug]/preferred-relief-assignments/route.ts index 7c09bd2..c2aa399 100644 --- a/src/app/api/[slug]/preferred-relief-assignments/route.ts +++ b/src/app/api/[slug]/preferred-relief-assignments/route.ts @@ -90,12 +90,11 @@ export async function POST(req: Request, { params }: Params) { // ── Race shape 1: an internal `ReliefAssignment` already covers // this slot. Refuse — internal staff are the authoritative source. - const existingInternal = await prisma.reliefAssignment.findUnique({ + const existingInternal = await prisma.reliefAssignment.findFirst({ where: { - timetableEntryId_date: { - timetableEntryId: input.timetableEntryId, - date: input.date, - }, + timetableEntryId: input.timetableEntryId, + date: input.date, + status: "ACTIVE", }, include: { reliefTeacher: { select: { name: true } } }, }); diff --git a/src/app/api/absence-reports/route.ts b/src/app/api/absence-reports/route.ts index b4d4fda..ed40162 100644 --- a/src/app/api/absence-reports/route.ts +++ b/src/app/api/absence-reports/route.ts @@ -378,6 +378,18 @@ export async function POST(request: NextRequest) { return created; }); + // Cascading absence: if this teacher was assigned as relief for someone + // else on the affected dates, cancel those assignments so the periods + // go back to the dashboard for re-assignment. + const teacherRecord = await prisma.teacher.findFirst({ + where: { id: resolvedTeacherId, schoolId: resolvedSchoolId! }, + select: { id: true }, + }); + if (teacherRecord) { + const { cancelAssignmentsForAbsentRelief } = await import("@/lib/cascadingAbsence"); + await cancelAssignmentsForAbsentRelief(resolvedTeacherId, start, end); + } + await recordAbsenceAttempt({ schoolId: resolvedSchoolId, teacherId: resolvedTeacherId, diff --git a/src/app/api/auto-assign/route.ts b/src/app/api/auto-assign/route.ts index d035358..a9673ff 100644 --- a/src/app/api/auto-assign/route.ts +++ b/src/app/api/auto-assign/route.ts @@ -61,7 +61,7 @@ export async function POST(request: NextRequest) { select: { teacherId: true, periodId: true, className: true, subject: true }, }), prisma.reliefAssignment.findMany({ - where: { schoolId, date: dateUTC, reliefTeacherId: { in: proposedTeacherIds } }, + where: { schoolId, date: dateUTC, status: "ACTIVE", reliefTeacherId: { in: proposedTeacherIds } }, select: { reliefTeacherId: true, timetableEntry: { select: { periodId: true } } }, }), getDayShiftCounts(schoolId, date), diff --git a/src/app/api/cron/daily-summary/route.ts b/src/app/api/cron/daily-summary/route.ts index 743ef42..81d9d24 100644 --- a/src/app/api/cron/daily-summary/route.ts +++ b/src/app/api/cron/daily-summary/route.ts @@ -73,7 +73,7 @@ export async function GET(request: NextRequest) { // Get assignments const assignments = await prisma.reliefAssignment.findMany({ - where: { schoolId: school.id, date: dateUTC }, + where: { schoolId: school.id, date: dateUTC, status: "ACTIVE" }, include: { reliefTeacher: true, timetableEntry: { include: { period: true } }, diff --git a/src/app/api/dashboard/available-teachers/route.ts b/src/app/api/dashboard/available-teachers/route.ts index 8ec897b..2d43e20 100644 --- a/src/app/api/dashboard/available-teachers/route.ts +++ b/src/app/api/dashboard/available-teachers/route.ts @@ -99,7 +99,7 @@ export async function GET(request: NextRequest) { }, }), prisma.reliefAssignment.findMany({ - where: { schoolId, date: dateUTC }, + where: { schoolId, date: dateUTC, status: "ACTIVE" }, select: { reliefTeacherId: true, timetableEntry: { select: { periodId: true } }, diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts index 2289c04..88d2826 100644 --- a/src/app/api/dashboard/route.ts +++ b/src/app/api/dashboard/route.ts @@ -59,14 +59,13 @@ export async function GET(request: NextRequest) { include: { teacher: { select: { id: true, name: true } }, reliefAssignments: { - where: { date: dateUTC }, + where: { date: dateUTC, status: "ACTIVE" }, select: { id: true, timetableEntryId: true, createdAt: true, acknowledgedAt: true, reliefTeacher: { select: { name: true } }, - // Phase 15 / D2 — attribution surface in the assignment row. assignedBy: { select: { name: true, email: true } }, }, }, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index ab4cba4..446d0cf 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -25,6 +25,7 @@ export async function GET(request: NextRequest) { const assignments = await prisma.reliefAssignment.findMany({ where: { schoolId, + status: "ACTIVE", date: { gte: fromDate, lte: toDate }, }, include: { diff --git a/src/app/api/relief-assignments/route.ts b/src/app/api/relief-assignments/route.ts index 2a294a6..ec358d9 100644 --- a/src/app/api/relief-assignments/route.ts +++ b/src/app/api/relief-assignments/route.ts @@ -1,4 +1,3 @@ -import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { requireSchool } from "@/lib/auth"; import { NextRequest, NextResponse } from "next/server"; @@ -46,71 +45,63 @@ export async function POST(request: NextRequest) { ); } - // Pre-check: teacher already covering this period at the same time? - // (Race shape 1 above — explicit lookup catches the friendly case.) - const existing = await prisma.reliefAssignment.findFirst({ + // Pre-check 1: teacher already covering this period at the same time? + const teacherBusy = await prisma.reliefAssignment.findFirst({ where: { reliefTeacherId, date, + status: "ACTIVE", timetableEntry: { periodId: timetableEntry.periodId }, }, }); - if (existing) { + if (teacherBusy) { return NextResponse.json( { error: "Teacher is already covering another class at this time." }, { status: 409 } ); } - try { - const assignment = await prisma.reliefAssignment.create({ - data: { - absenceReportId, - timetableEntryId, - reliefTeacherId, - date, - schoolId, - assignedByUserId: session.user.id, - }, - }); - - // Fire-and-forget: send notification email (dynamic import so - // notifier.ts module-level checks don't crash the route) - import("@/lib/notifyAssignment") - .then(({ notifyReliefAssignment }) => - notifyReliefAssignment(assignment.id) - ) - .catch(() => {}); + // Pre-check 2: slot already has an ACTIVE assignment? + const slotTaken = await prisma.reliefAssignment.findFirst({ + where: { timetableEntryId, date, status: "ACTIVE" }, + include: { + reliefTeacher: { select: { name: true } }, + assignedBy: { select: { name: true, email: true } }, + }, + }); - return NextResponse.json(assignment, { status: 201 }); - } catch (e) { - // Race shape 2: unique violation on (timetableEntryId, date). Someone - // else assigned this period between our pre-check and the write. - if ( - e instanceof Prisma.PrismaClientKnownRequestError && - e.code === "P2002" - ) { - const winner = await prisma.reliefAssignment.findUnique({ - where: { timetableEntryId_date: { timetableEntryId, date } }, - include: { - reliefTeacher: { select: { name: true } }, - assignedBy: { select: { name: true, email: true } }, - }, - }); - const winnerName = winner?.reliefTeacher.name ?? "another teacher"; - const winnerBy = - winner?.assignedBy?.name ?? winner?.assignedBy?.email ?? null; - const message = winnerBy - ? `${winnerName} was just assigned to this period by ${winnerBy}. Refresh to see the latest.` - : `${winnerName} was just assigned to this period. Refresh to see the latest.`; - return NextResponse.json( - { error: message, code: "STALE_DASHBOARD" }, - { status: 409 } - ); - } - throw e; + if (slotTaken) { + const winnerName = slotTaken.reliefTeacher.name; + const winnerBy = slotTaken.assignedBy?.name ?? slotTaken.assignedBy?.email ?? null; + const message = winnerBy + ? `${winnerName} was just assigned to this period by ${winnerBy}. Refresh to see the latest.` + : `${winnerName} is already assigned to this period. Refresh to see the latest.`; + return NextResponse.json( + { error: message, code: "STALE_DASHBOARD" }, + { status: 409 } + ); } + + const assignment = await prisma.reliefAssignment.create({ + data: { + absenceReportId, + timetableEntryId, + reliefTeacherId, + date, + schoolId, + assignedByUserId: session.user.id, + }, + }); + + // Fire-and-forget: send notification email + import("@/lib/notifyAssignment") + .then(({ notifyReliefAssignment }) => + notifyReliefAssignment(assignment.id) + ) + .catch(() => {}); + + return NextResponse.json(assignment, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : ""; if (message === "Unauthorized" || message === "No school associated with user") { diff --git a/src/lib/autoAssign.ts b/src/lib/autoAssign.ts index f613230..776db55 100644 --- a/src/lib/autoAssign.ts +++ b/src/lib/autoAssign.ts @@ -168,7 +168,7 @@ export async function autoAssign( // --- 3. Remove already-assigned slots --- const existingAssignments = await prisma.reliefAssignment.findMany({ - where: { schoolId, date: dateUTC }, + where: { schoolId, date: dateUTC, status: "ACTIVE" }, select: { timetableEntryId: true, reliefTeacherId: true, diff --git a/src/lib/autoAssignRules.ts b/src/lib/autoAssignRules.ts index c85c51f..cea7ffc 100644 --- a/src/lib/autoAssignRules.ts +++ b/src/lib/autoAssignRules.ts @@ -728,7 +728,7 @@ export async function getShiftCountsForWindow( const range = resolveWorkloadWindowRange(dateStr, cfg); const rows = await prisma.reliefAssignment.groupBy({ by: ["reliefTeacherId"], - where: { schoolId, date: { gte: range.gte, lt: range.lt } }, + where: { schoolId, status: "ACTIVE", date: { gte: range.gte, lt: range.lt } }, _count: { _all: true }, }); return new Map(rows.map((row) => [row.reliefTeacherId, row._count._all])); @@ -761,7 +761,7 @@ export async function getDayShiftCounts( const rows = await prisma.reliefAssignment.groupBy({ by: ["reliefTeacherId"], - where: { schoolId, date: { gte: dateUTC, lt: nextDayUTC } }, + where: { schoolId, status: "ACTIVE", date: { gte: dateUTC, lt: nextDayUTC } }, _count: { _all: true }, }); return new Map(rows.map((row) => [row.reliefTeacherId, row._count._all])); diff --git a/src/lib/cascadingAbsence.ts b/src/lib/cascadingAbsence.ts new file mode 100644 index 0000000..68a71e1 --- /dev/null +++ b/src/lib/cascadingAbsence.ts @@ -0,0 +1,38 @@ +import { prisma } from "@/lib/prisma"; + +/** + * When a teacher submits an absence, check if they have any ACTIVE relief + * assignments on the affected dates. If so, cancel those assignments + * (CANCELLED_ASSIGNEE_ABSENT) so the periods surface back on the dashboard + * for re-assignment. + * + * Returns the IDs of cancelled assignments (empty array if none). + */ +export async function cancelAssignmentsForAbsentRelief( + teacherId: string, + startDate: Date, + endDate: Date, +): Promise { + const affected = await prisma.reliefAssignment.findMany({ + where: { + reliefTeacherId: teacherId, + status: "ACTIVE", + date: { gte: startDate, lte: endDate }, + }, + select: { id: true }, + }); + + if (affected.length === 0) return []; + + const ids = affected.map((a) => a.id); + + await prisma.reliefAssignment.updateMany({ + where: { id: { in: ids } }, + data: { + status: "CANCELLED_ASSIGNEE_ABSENT", + cancelledAt: new Date(), + }, + }); + + return ids; +} diff --git a/src/lib/scheduling/absenceCoverage.ts b/src/lib/scheduling/absenceCoverage.ts index 5121e55..5e0dee5 100644 --- a/src/lib/scheduling/absenceCoverage.ts +++ b/src/lib/scheduling/absenceCoverage.ts @@ -149,7 +149,7 @@ export async function resolveUnfilledPeriods( if (universe.length === 0) return []; const internal = await prisma.reliefAssignment.findMany({ - where: { absenceReportId: absence.id, date: dateUtc }, + where: { absenceReportId: absence.id, date: dateUtc, status: "ACTIVE" }, include: { timetableEntry: { select: { periodId: true } } }, }); const internalCovered = new Set(