Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ enum PreferredTeacherSource {
REMS
}

enum ReliefAssignmentStatus {
ACTIVE
CANCELLED_ASSIGNEE_ABSENT
CANCELLED_ADMIN
}

// ─── Multi-tenancy ──────────────────────────────────────

model School {
Expand Down Expand Up @@ -399,29 +405,31 @@ 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)
reliefTeacher Teacher @relation(fields: [reliefTeacherId], references: [id], onDelete: Cascade)
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])
Expand Down
36 changes: 36 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 4 additions & 5 deletions src/app/api/[slug]/preferred-relief-assignments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
});
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/absence-reports/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +389 to +390
}
Comment on lines +384 to +391

await recordAbsenceAttempt({
schoolId: resolvedSchoolId,
teacherId: resolvedTeacherId,
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auto-assign/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
}),
Comment on lines 63 to 66
getDayShiftCounts(schoolId, date),
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/cron/daily-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/dashboard/available-teachers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
3 changes: 1 addition & 2 deletions src/app/api/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
},
},
Expand Down
1 change: 1 addition & 0 deletions src/app/api/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
93 changes: 42 additions & 51 deletions src/app/api/relief-assignments/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 } },
Comment on lines +65 to +69
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") {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/autoAssign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/lib/autoAssignRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down Expand Up @@ -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]));
Expand Down
38 changes: 38 additions & 0 deletions src/lib/cascadingAbsence.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
const affected = await prisma.reliefAssignment.findMany({
where: {
reliefTeacherId: teacherId,
status: "ACTIVE",
date: { gte: startDate, lte: endDate },
},
Comment on lines +1 to +21
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;
}
2 changes: 1 addition & 1 deletion src/lib/scheduling/absenceCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down