From 2db87a122750aeb7410740f4df94058b52ef0bfd Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Sun, 2 Aug 2026 15:17:18 +0200 Subject: [PATCH] Add 4 files, update 15 files --- package.json | 3 +- plans/role-communication-leave-workflow.md | 49 ++++++++++++++++ .../migration.sql | 20 +++++++ prisma/schema.prisma | 11 ++++ src/app/api/calendar-events/route.ts | 41 ++++++++++++-- src/app/api/classes/[id]/route.ts | 20 +++++-- src/app/api/communication-rooms/[id]/route.ts | 11 +++- src/app/api/communication-rooms/route.ts | 36 +++++++----- src/app/api/illness-reports/route.ts | 11 +++- src/app/api/parent-links/[id]/route.ts | 26 +++++++++ src/app/api/students/bulk/route.ts | 14 +++++ .../absences/[id]/approve/route.ts | 56 +++++++++++++++++++ src/app/api/substitutes/absences/route.ts | 24 +++++++- src/app/api/users/role/route.ts | 5 ++ src/components/communication-view.tsx | 38 +++++++++++-- src/components/illness-reporting-view.tsx | 43 +++++++++++++- src/lib/role-access.test.ts | 10 ++++ src/lib/teacher-leave-policy.test.ts | 15 +++++ src/lib/teacher-leave-policy.ts | 14 +++++ 19 files changed, 414 insertions(+), 33 deletions(-) create mode 100644 prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql create mode 100644 src/app/api/substitutes/absences/[id]/approve/route.ts create mode 100644 src/lib/teacher-leave-policy.test.ts create mode 100644 src/lib/teacher-leave-policy.ts diff --git a/package.json b/package.json index ef1a7b5..f5661e4 100755 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "db:seed": "bun prisma/seed.ts", "db:seed-demo": "bun prisma/seed-demo.ts", "db:setup-demo": "bun run db:push && bun run db:seed-demo", - "test": "bun test" + "test": "bun test", + "typecheck": "next build --webpack && tsc --noEmit --pretty false" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/plans/role-communication-leave-workflow.md b/plans/role-communication-leave-workflow.md index fd099dd..2224cd2 100644 --- a/plans/role-communication-leave-workflow.md +++ b/plans/role-communication-leave-workflow.md @@ -83,6 +83,8 @@ Implemented baseline: ## Phase 3: Student And Parent Leave Workflow +Status: implemented + Scope: - Generalize `IllnessReport` into a leave request workflow for illness, @@ -106,6 +108,17 @@ Verification: - Workflow tests covering pending, parent-approved, teacher-notified, admin-approved, rejected, and calendar-created states. +Implemented baseline: + +- Student requests require a linked parent approval. +- Parent-created requests pass the parent approval stage immediately. +- Parent approval notifies the responsible teacher. +- Final approval is restricted to school and super administrators. +- Final approval creates one class-linked, all-day calendar event and stores the + event reference on the leave request. +- Evidence documents remain on the leave request and are not placed in calendar + event notes. + ## Phase 4: Teacher Leave And Calendar Visibility Scope: @@ -130,6 +143,8 @@ Verification: ## Phase 5: UI Completion And Regression Coverage +Status: implemented + Scope: - Render role-specific menu items and communication/leave screens. @@ -145,6 +160,16 @@ Verification: - `pnpm exec tsc --noEmit --pretty false`, `pnpm lint`, `pnpm test`, and `pnpm build`. +Implemented baseline: + +- Leave cards distinguish parent approval from final administrator approval and + show calendar publication only after the final approval. +- Student and parent conversation panels expose the escalation action only + after the five-business-day threshold. +- Escalated rooms visibly identify their administrative escalation state. +- `pnpm typecheck` provides a build-first typecheck that avoids stale generated + Next type files. + ## Dependency Order 1. Phase 1 must precede all others because it establishes reusable access @@ -158,3 +183,27 @@ Verification: - All schema changes use additive columns/tables first. - No existing data is deleted by migrations. - New policy checks default to deny for unknown roles and missing ownership. + +## Phase 6: Endpoint Authorization Hardening + +Status: implemented + +- User role assignment now uses the same administrator target-protection policy + as user update/delete operations. +- Class updates and deletion are restricted to school administrators; all class + reads validate class membership or administrator scope. +- Bulk student creation validates the caller school and a teacher's assigned + class before creating records. +- Parent-link reads and edits validate teacher student scope and school scope. + +## Phase 7: Workflow Scope Hardening + +Status: implemented + +- Parent communication creation requires an explicitly linked child rather than + inferring a profile from the parent's own account. +- Parent and student leave list filters reject unlinked student identifiers. +- Communication room deletion is limited to school/super administrators; + vice principals cannot remove conversation records. +- Administrative room closure verifies the administrator belongs to the room's + school. diff --git a/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql new file mode 100644 index 0000000..9289b0c --- /dev/null +++ b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql @@ -0,0 +1,20 @@ +ALTER TABLE "teacher_absences" + ADD COLUMN "approvalStatus" TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN "approvedBy" TEXT, + ADD COLUMN "approvedAt" TIMESTAMP(3), + ADD COLUMN "privateAdminNotes" TEXT, + ADD COLUMN "documentUrl" TEXT, + ADD COLUMN "calendarEventId" TEXT; + +ALTER TABLE "teacher_absences" + ADD CONSTRAINT "teacher_absences_approvedBy_fkey" + FOREIGN KEY ("approvedBy") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "teacher_absences" + ADD CONSTRAINT "teacher_absences_calendarEventId_fkey" + FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId"); +CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abc82b4..d36f0af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -199,6 +199,7 @@ model User { illnessReports IllnessReport[] illnessReportApprovals IllnessReport[] @relation("IllnessApprover") illnessReportAdminApprovals IllnessReport[] @relation("IllnessAdminApprover") + teacherAbsenceApprovals TeacherAbsence[] @relation("TeacherAbsenceApprover") communicationRoomMembers CommunicationRoomMember[] communicationMessages CommunicationMessage[] counselingAppointments CounselingAppointment[] @relation("Counselor") @@ -1157,6 +1158,7 @@ model CalendarEvent { childEvents CalendarEvent[] @relation("EventRecurrence") examPlan ExamPlan? illnessReport IllnessReport? + teacherAbsence TeacherAbsence? @relation("TeacherAbsenceCalendar") @@index([schoolId]) @@index([teacherId]) @@ -3217,16 +3219,25 @@ model TeacherAbsence { reason String? status String @default("reported") // reported, covered, cancelled notes String? + approvalStatus String @default("pending") // pending, approved, rejected + approvedBy String? + approvedAt DateTime? + privateAdminNotes String? + documentUrl String? + calendarEventId String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt school School @relation(fields: [schoolId], references: [id]) teacher User @relation(fields: [teacherId], references: [id]) + approver User? @relation("TeacherAbsenceApprover", fields: [approvedBy], references: [id]) + calendarEvent CalendarEvent? @relation("TeacherAbsenceCalendar", fields: [calendarEventId], references: [id]) assignments SubstitutionAssignment[] @@index([schoolId]) @@index([teacherId]) @@index([startDate]) + @@index([approvalStatus]) @@map("teacher_absences") } diff --git a/src/app/api/calendar-events/route.ts b/src/app/api/calendar-events/route.ts index 384dd38..94d31f3 100755 --- a/src/app/api/calendar-events/route.ts +++ b/src/app/api/calendar-events/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { getTeacherClassIds } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; import { addDays, addWeeks, addMonths, addYears, format, getDay } from 'date-fns'; interface RecurrencePattern { @@ -124,6 +126,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 }); } + if (!session.user || (!isAdministrator(session.user.role) && session.user.schoolId !== schoolId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user.role === 'TEACHER' && classGroupId) { + const classIds = await getTeacherClassIds(session.user.id); + if (!classIds.includes(classGroupId)) { + return NextResponse.json({ error: 'Teachers can only create events for their own classes' }, { status: 403 }); + } + } + if (session.user.role === 'STUDENT' || session.user.role === 'PARENT' || session.user.role === 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Parse recurrence pattern let parsedPattern: RecurrencePattern | null = null; if (recurrencePattern && recurrencePattern.type && recurrencePattern.type !== 'none') { @@ -220,6 +235,10 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + if (!session.user || (!isAdministrator(session.user.role) && session.user.schoolId !== schoolId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + let year: number; let monthIndex: number; if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) { @@ -235,11 +254,25 @@ export async function GET(request: Request) { const startOfMonth = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0)); const endOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999)); + const eventWhere: Record = { + schoolId, + date: { gte: startOfMonth, lte: endOfMonth }, + }; + if (session.user.role === 'TEACHER') { + const classIds = await getTeacherClassIds(session.user.id); + eventWhere.OR = [{ teacherId: session.user.id }, { classGroupId: { in: classIds } }]; + } else if (session.user.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } }); + const enrollments = student ? await db.enrollment.findMany({ where: { studentId: student.id, endDate: null }, select: { classGroupId: true } }) : []; + eventWhere.classGroupId = { in: enrollments.map((enrollment) => enrollment.classGroupId) }; + } else if (session.user.role === 'PARENT') { + const links = await db.parentStudentLink.findMany({ where: { parentId: session.user.id }, select: { studentId: true } }); + const enrollments = await db.enrollment.findMany({ where: { studentId: { in: links.map((link) => link.studentId) }, endDate: null }, select: { classGroupId: true } }); + eventWhere.classGroupId = { in: enrollments.map((enrollment) => enrollment.classGroupId) }; + } + const events = await db.calendarEvent.findMany({ - where: { - schoolId, - date: { gte: startOfMonth, lte: endOfMonth }, - }, + where: eventWhere, include: { subject: { select: { id: true, name: true } }, classGroup: { select: { id: true, name: true } }, diff --git a/src/app/api/classes/[id]/route.ts b/src/app/api/classes/[id]/route.ts index 946d289..fd2d04b 100755 --- a/src/app/api/classes/[id]/route.ts +++ b/src/app/api/classes/[id]/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; const updateClassSchema = z.object({ name: z.string().min(1).optional(), @@ -64,6 +66,10 @@ export async function GET( return NextResponse.json({ error: 'Class not found' }, { status: 404 }); } + if (!session.user || !(await canAccessClass(session.user, id))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const result = { ...classGroup, studentCount: classGroup._count.enrollments, @@ -94,11 +100,7 @@ export async function PUT( return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - if ( - session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' - ) { + if (!isAdministrator(session.user?.role)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -117,6 +119,10 @@ export async function PUT( return NextResponse.json({ error: 'Class not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // If updating responsibleTeacherId, validate the teacher exists if (parsed.data.responsibleTeacherId !== undefined) { const teacherId = parsed.data.responsibleTeacherId; @@ -226,6 +232,10 @@ export async function DELETE( return NextResponse.json({ error: 'Class not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + await db.classGroup.delete({ where: { id } }); await db.auditLog.create({ diff --git a/src/app/api/communication-rooms/[id]/route.ts b/src/app/api/communication-rooms/[id]/route.ts index 8640e11..b74bc58 100755 --- a/src/app/api/communication-rooms/[id]/route.ts +++ b/src/app/api/communication-rooms/[id]/route.ts @@ -124,6 +124,9 @@ export async function PUT( if (role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'SUPER_ADMIN') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (role !== 'SUPER_ADMIN' && room.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } } const updated = await db.communicationRoom.update({ @@ -192,10 +195,14 @@ export async function DELETE( const role = session.user?.role; - // Only admin can delete rooms - if (role !== 'SUPER_ADMIN' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { + // Only school administrators can delete rooms. Vice principals may manage + // operational states but must not remove communication records. + if (role !== 'SUPER_ADMIN' && role !== 'SCHOOL_ADMIN') { return NextResponse.json({ error: 'Only admins can delete rooms' }, { status: 403 }); } + if (role !== 'SUPER_ADMIN' && room.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } await db.communicationMessage.deleteMany({ where: { roomId: id } }); await db.communicationRoomMember.deleteMany({ where: { roomId: id } }); diff --git a/src/app/api/communication-rooms/route.ts b/src/app/api/communication-rooms/route.ts index 6b5e802..f7b2857 100755 --- a/src/app/api/communication-rooms/route.ts +++ b/src/app/api/communication-rooms/route.ts @@ -138,19 +138,27 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Teachers should use staff communication channels' }, { status: 400 }); } - // Find the student's class and the responsible teacher + // Student direct messages target their own profile. Parent direct messages + // must explicitly name one linked child. + const requestedStudentId = body.studentId as string | undefined; + if (role === 'PARENT' && !requestedStudentId) { + return NextResponse.json({ error: 'studentId is required for parent conversations' }, { status: 400 }); + } + const studentWhere = role === 'PARENT' + ? { id: requestedStudentId, parentStudentLinks: { some: { parentId: userId } } } + : { + OR: [ + { userId }, + { + userId: null, + firstName: session.user?.firstName, + lastName: session.user?.lastName, + schoolId: session.user?.schoolId, + }, + ], + }; const student = await db.student.findFirst({ - where: { - OR: [ - { userId }, - { - userId: null, - firstName: session.user?.firstName, - lastName: session.user?.lastName, - schoolId: session.user?.schoolId, - }, - ], - }, + where: studentWhere, include: { enrollments: { where: { endDate: null }, @@ -183,7 +191,7 @@ export async function POST(request: Request) { // Check if there's already an active/requested room const existingRoom = await db.communicationRoom.findFirst({ where: { - studentId: userId, + studentId: role === 'PARENT' ? student.userId ?? userId : userId, teacherId: teacher.id, status: { in: ['requested', 'active'] }, }, @@ -196,7 +204,7 @@ export async function POST(request: Request) { const room = await db.communicationRoom.create({ data: { schoolId: student.schoolId, - studentId: userId, + studentId: role === 'PARENT' ? student.userId ?? userId : userId, teacherId: teacher.id, roomType: 'chat', status: 'requested', diff --git a/src/app/api/illness-reports/route.ts b/src/app/api/illness-reports/route.ts index e315bfe..ca06ab5 100755 --- a/src/app/api/illness-reports/route.ts +++ b/src/app/api/illness-reports/route.ts @@ -63,7 +63,16 @@ export async function GET(request: Request) { // Super admin can see all } - if (studentId) where.studentId = studentId; + if (studentId) { + if (role === 'STUDENT' && where.studentId !== studentId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (role === 'PARENT') { + const linked = await db.parentStudentLink.findFirst({ where: { parentId: userId, studentId }, select: { id: true } }); + if (!linked) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + where.studentId = studentId; + } if (status) where.status = status; if (parentApprovalStatus) where.parentApprovalStatus = parentApprovalStatus; diff --git a/src/app/api/parent-links/[id]/route.ts b/src/app/api/parent-links/[id]/route.ts index 5469291..9ec1980 100755 --- a/src/app/api/parent-links/[id]/route.ts +++ b/src/app/api/parent-links/[id]/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; export async function GET( request: Request, @@ -52,6 +54,16 @@ export async function GET( return NextResponse.json({ error: 'Link not found' }, { status: 404 }); } + if (session.user?.role === 'TEACHER' && (!session.user || !(await canAccessStudent(session.user, link.studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!isAdministrator(session.user?.role) && session.user?.role !== 'TEACHER' && session.user?.role !== 'PARENT') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'PARENT' && link.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Parents can only view their own links if (session.user?.role === 'PARENT' && link.parentId !== session.user.id) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); @@ -83,6 +95,13 @@ export async function PUT( return NextResponse.json({ error: 'Link not found' }, { status: 404 }); } + if (session.user?.role === 'TEACHER' && (!session.user || !(await canAccessStudent(session.user, link.studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'PARENT' && link.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Only admins, teachers, or the parent can update if ( session.user?.role !== 'TEACHER' && @@ -143,6 +162,13 @@ export async function DELETE( return NextResponse.json({ error: 'Link not found' }, { status: 404 }); } + if (session.user?.role === 'TEACHER' && (!session.user || !(await canAccessStudent(session.user, link.studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'PARENT' && link.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Only admins, teachers, or the parent can delete if ( session.user?.role !== 'TEACHER' && diff --git a/src/app/api/students/bulk/route.ts b/src/app/api/students/bulk/route.ts index fcd314d..c521aeb 100755 --- a/src/app/api/students/bulk/route.ts +++ b/src/app/api/students/bulk/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { getTeacherClassIds } from '@/lib/access-policy'; const studentItemSchema = z.object({ firstName: z.string().min(1), @@ -43,6 +44,19 @@ export async function POST(request: Request) { const { schoolId, classGroupId, schoolYearId, students } = parsed.data; + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role === 'TEACHER') { + if (!classGroupId) { + return NextResponse.json({ error: 'Teachers must select one of their classes' }, { status: 400 }); + } + const classIds = await getTeacherClassIds(session.userId); + if (!classIds.includes(classGroupId)) { + return NextResponse.json({ error: 'Teachers can only import students into their own classes' }, { status: 403 }); + } + } + // Validate class & school year if (classGroupId) { const cls = await db.classGroup.findUnique({ where: { id: classGroupId } }); diff --git a/src/app/api/substitutes/absences/[id]/approve/route.ts b/src/app/api/substitutes/absences/[id]/approve/route.ts new file mode 100644 index 0000000..a284bad --- /dev/null +++ b/src/app/api/substitutes/absences/[id]/approve/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { canApproveTeacherLeave, canCreateTeacherLeaveCalendarEvent } from '@/lib/teacher-leave-policy'; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!canApproveTeacherLeave(session.user?.role)) { + return NextResponse.json({ error: 'Only administrators can approve teacher leave' }, { status: 403 }); + } + + const { id } = await params; + const { action, privateAdminNotes } = await request.json(); + if (action !== 'approve' && action !== 'reject') { + return NextResponse.json({ error: 'action must be approve or reject' }, { status: 400 }); + } + + const absence = await db.teacherAbsence.findUnique({ where: { id } }); + if (!absence) return NextResponse.json({ error: 'Absence not found' }, { status: 404 }); + if (session.user?.role !== 'SUPER_ADMIN' && absence.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (absence.approvalStatus !== 'pending') { + return NextResponse.json({ error: 'Leave request already processed' }, { status: 400 }); + } + + const approvalStatus = action === 'approve' ? 'approved' : 'rejected'; + const updated = await db.teacherAbsence.update({ + where: { id }, + data: { + approvalStatus, + approvedBy: session.userId, + approvedAt: new Date(), + privateAdminNotes: privateAdminNotes || null, + status: approvalStatus === 'approved' ? 'reported' : 'cancelled', + }, + }); + + if (canCreateTeacherLeaveCalendarEvent(updated)) { + const event = await db.calendarEvent.create({ + data: { + schoolId: updated.schoolId, + teacherId: updated.teacherId, + title: 'Approved teacher leave', + date: updated.startDate, + eventType: 'teacher_leave', + notes: null, + allDay: true, + }, + }); + await db.teacherAbsence.update({ where: { id }, data: { calendarEventId: event.id } }); + } + + return NextResponse.json(updated); +} diff --git a/src/app/api/substitutes/absences/route.ts b/src/app/api/substitutes/absences/route.ts index 93cc2d9..6cf1ecd 100644 --- a/src/app/api/substitutes/absences/route.ts +++ b/src/app/api/substitutes/absences/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; // GET /api/substitutes/absences - List teacher absences export async function GET(req: NextRequest) { try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); const { searchParams } = new URL(req.url); const schoolId = searchParams.get('schoolId'); const teacherId = searchParams.get('teacherId'); @@ -16,9 +19,13 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const where: Record = { schoolId }; + if (session.user?.role === 'TEACHER') where.teacherId = session.userId; - if (teacherId) where.teacherId = teacherId; + if (teacherId && session.user?.role !== 'TEACHER') where.teacherId = teacherId; if (type) where.type = type; if (status) where.status = status; @@ -57,6 +64,8 @@ export async function GET(req: NextRequest) { // POST /api/substitutes/absences - Create a teacher absence export async function POST(req: NextRequest) { try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); const body = await req.json(); const { schoolId, teacherId, type, startDate, endDate, reason, status, notes } = body; @@ -64,6 +73,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'schoolId, teacherId, type, startDate, and endDate are required' }, { status: 400 }); } + const isTeacherRequest = session.user?.role === 'TEACHER'; + if (isTeacherRequest && teacherId !== session.userId) { + return NextResponse.json({ error: 'Teachers can only submit their own leave request' }, { status: 403 }); + } + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const absence = await db.teacherAbsence.create({ data: { schoolId, @@ -74,6 +94,8 @@ export async function POST(req: NextRequest) { reason: reason || null, status: status || 'reported', notes: notes || null, + approvalStatus: 'pending', + documentUrl: body.documentUrl || null, }, include: { teacher: { select: { id: true, firstName: true, lastName: true, email: true } }, diff --git a/src/app/api/users/role/route.ts b/src/app/api/users/role/route.ts index 8ec94b3..2d86fa4 100755 --- a/src/app/api/users/role/route.ts +++ b/src/app/api/users/role/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canManageUser } from '@/lib/role-access'; const changeRoleSchema = z.object({ userId: z.string().min(1), @@ -48,6 +49,10 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } + if (!canManageUser(session.user?.role, existing.role, userId === session.userId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // School admins can only manage users from their own school if ( session.user?.role === 'SCHOOL_ADMIN' && diff --git a/src/components/communication-view.tsx b/src/components/communication-view.tsx index 7e53ee1..b823b1d 100644 --- a/src/components/communication-view.tsx +++ b/src/components/communication-view.tsx @@ -54,6 +54,10 @@ interface CommunicationRoom { studentId: string; teacherId: string; roomType: string; + audienceType: string; + escalationEligibleAt: string | null; + escalatedAt: string | null; + resolutionStatus: string; status: string; requestedBy: string; acceptedAt: string | null; @@ -532,6 +536,17 @@ function ChatArea({ } }; + const handleEscalate = async () => { + try { + await apiPut(`/api/communication-rooms/${roomId}`, { action: 'escalate' }); + toast.success('Conversation escalated to administration'); + fetchRoom(); + onRefresh(); + } catch { + toast.error('This conversation cannot be escalated yet'); + } + }; + // Voice recording with Web Audio API const startRecording = async () => { try { @@ -704,6 +719,11 @@ function ChatArea({ const isStudent = room?.studentId === currentUserId; const otherPerson = isStudent ? room?.teacher : room?.student; const isAdmin = role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL' || role === 'SUPER_ADMIN'; + const canEscalate = (role === 'STUDENT' || role === 'PARENT') + && room?.audienceType === 'direct' + && room?.resolutionStatus !== 'resolved' + && !room?.escalatedAt + && Boolean(room?.escalationEligibleAt && new Date(room.escalationEligibleAt) <= new Date()); const formatTime = (dateStr: string) => { const date = new Date(dateStr); @@ -766,12 +786,17 @@ function ChatArea({ room?.status === 'active' ? t('communication.active') : t('communication.closed')} - {isAdmin && ( + {isAdmin && ( {t('communication.read_only')} - )} + )} + {room?.escalatedAt && ( + + Escalated + + )} @@ -788,7 +813,7 @@ function ChatArea({ )} - {role === 'TEACHER' && room?.status === 'active' && ( + {role === 'TEACHER' && room?.status === 'active' && ( <> - )} + )} + {canEscalate && ( + + )} diff --git a/src/components/illness-reporting-view.tsx b/src/components/illness-reporting-view.tsx index 9495c30..912da5f 100755 --- a/src/components/illness-reporting-view.tsx +++ b/src/components/illness-reporting-view.tsx @@ -55,6 +55,11 @@ interface IllnessReport { parentApprovalStatus: string; parentApprovedBy: string | null; parentApprovedAt: string | null; + leaveType: string; + teacherNotifiedAt: string | null; + adminApprovalStatus: string; + adminApprovedAt: string | null; + calendarEventId: string | null; isVisibleToTeacher: boolean; isVisibleToAdmin: boolean; status: string; @@ -334,6 +339,15 @@ function ReportCard({ const [isApproving, setIsApproving] = useState(false); const getStatusBadge = () => { + if (report.adminApprovalStatus === 'approved') { + return Approved and on calendar; + } + if (report.adminApprovalStatus === 'rejected') { + return Rejected by administration; + } + if (report.parentApprovalStatus === 'approved') { + return Awaiting administration; + } switch (report.parentApprovalStatus) { case 'pending': return {t('illness.pending_approval')}; @@ -381,6 +395,10 @@ function ReportCard({ } }; + const canAdminApprove = (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') + && report.parentApprovalStatus === 'approved' + && report.adminApprovalStatus === 'pending'; + const handleDelete = async () => { try { await apiDelete(`/api/illness-reports/${report.id}`); @@ -440,7 +458,8 @@ function ReportCard({ )} + {canAdminApprove && ( + <> + + + + )} diff --git a/src/lib/role-access.test.ts b/src/lib/role-access.test.ts index bc38295..53f8ce5 100644 --- a/src/lib/role-access.test.ts +++ b/src/lib/role-access.test.ts @@ -50,11 +50,21 @@ describe('role access', () => { expect(canAccessView('PARENT', 'parent-portal')).toBeTrue(); }); + test('keeps student and parent menus free of staff-only administration tools', () => { + for (const role of ['STUDENT', 'PARENT'] as const) { + expect(canAccessView(role, 'settings')).toBeFalse(); + expect(canAccessView(role, 'analytics')).toBeFalse(); + expect(canAccessView(role, 'student-detail')).toBeFalse(); + } + }); + test('does not grant vice principals administrator account control', () => { expect(canManageUser('VICE_PRINCIPAL', 'TEACHER')).toBeFalse(); expect(canManageUser('SCHOOL_ADMIN', 'SCHOOL_ADMIN', true)).toBeFalse(); expect(canManageUser('SCHOOL_ADMIN', 'SUPER_ADMIN')).toBeFalse(); expect(canManageUser('SUPER_ADMIN', 'SCHOOL_ADMIN')).toBeTrue(); + expect(canManageUser('SCHOOL_ADMIN', 'VICE_PRINCIPAL')).toBeTrue(); + expect(canManageUser('VICE_PRINCIPAL', 'SCHOOL_ADMIN')).toBeFalse(); }); test('allows student record mutation only to administrators and teachers', () => { diff --git a/src/lib/teacher-leave-policy.test.ts b/src/lib/teacher-leave-policy.test.ts new file mode 100644 index 0000000..234e8ea --- /dev/null +++ b/src/lib/teacher-leave-policy.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; +import { canApproveTeacherLeave, canCreateTeacherLeaveCalendarEvent, canViewTeacherPrivateNotes } from './teacher-leave-policy'; + +describe('teacher leave policy', () => { + test('keeps final approval and private notes with administrators', () => { + expect(canApproveTeacherLeave('SCHOOL_ADMIN')).toBeTrue(); + expect(canApproveTeacherLeave('VICE_PRINCIPAL')).toBeFalse(); + expect(canViewTeacherPrivateNotes('TEACHER')).toBeFalse(); + }); + + test('creates one calendar event after approval', () => { + expect(canCreateTeacherLeaveCalendarEvent({ approvalStatus: 'approved', calendarEventId: null })).toBeTrue(); + expect(canCreateTeacherLeaveCalendarEvent({ approvalStatus: 'pending', calendarEventId: null })).toBeFalse(); + }); +}); diff --git a/src/lib/teacher-leave-policy.ts b/src/lib/teacher-leave-policy.ts new file mode 100644 index 0000000..d97593e --- /dev/null +++ b/src/lib/teacher-leave-policy.ts @@ -0,0 +1,14 @@ +export function canApproveTeacherLeave(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export function canViewTeacherPrivateNotes(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export function canCreateTeacherLeaveCalendarEvent(absence: { + approvalStatus: string; + calendarEventId: string | null; +}): boolean { + return absence.approvalStatus === 'approved' && !absence.calendarEventId; +}