diff --git a/plans/role-communication-leave-workflow.md b/plans/role-communication-leave-workflow.md new file mode 100644 index 0000000..fd099dd --- /dev/null +++ b/plans/role-communication-leave-workflow.md @@ -0,0 +1,160 @@ +# Role, Communication, Leave, And Calendar Workflow + +## Objective + +Implement role-safe school operations for administrators, vice principals, +teachers, students, and parents. Every API must enforce the same permissions as +the navigation, and student and parent data must stay scoped to the user. + +## Invariants + +- `SCHOOL_ADMIN` and `SUPER_ADMIN` retain full school/system administration. +- `VICE_PRINCIPAL` never creates, deletes, promotes, resets passwords for, or + otherwise manages administrator accounts. +- Teachers can only view and update students enrolled in a class they teach. +- Students can only access their linked student record. +- Parents can only access records linked through `ParentStudentLink`. +- Authorization is enforced server-side; menu filtering is only a convenience. + +## Phase 1: Role And Data Access Foundation + +Status: implemented + +Scope: + +- Centralize administrator, user-management, and student-management policy. +- Restrict student lists, student detail pages, and class-student APIs. +- Restrict account management to administrators. +- Explicitly prevent vice principals from modifying administrator accounts. +- Narrow student, parent, and teacher menus to their allowed workflows. + +Files: + +- `src/lib/role-access.ts` +- `src/lib/access-policy.ts` +- `src/app/api/students/route.ts` +- `src/app/api/students/[id]/details/route.ts` +- `src/app/api/classes/[id]/students/route.ts` +- `src/app/api/users/route.ts` +- `src/app/api/users/[id]/route.ts` + +Verification: + +- `pnpm exec tsc --noEmit --pretty false` +- `pnpm lint` +- `pnpm test` + +## Phase 2: Communication Escalation And Groups + +Status: implemented + +Scope: + +- Add a unified communication policy service. +- Route student and parent messages to the responsible class teacher first. +- Persist first-contact timestamp, response timestamp, resolution status, and + escalation eligibility after five business days. +- Allow teachers to message administrators and vice principals directly. +- Add class and parent group room types, membership generation, and moderator + visibility for the responsible teacher and administrator. +- Prevent student and parent direct administrator threads before escalation. + +Schema work: + +- Extend `CommunicationRoom` with audience type, class group, escalation, + resolution, and visibility state. +- Extend `CommunicationMessage` with attachment metadata and moderation state. +- Add migration and backfill safe defaults for existing rooms. + +Verification: + +- Unit tests for five-business-day calculation and recipient selection. +- API tests for unauthorized direct escalation and group membership isolation. + +Implemented baseline: + +- Direct student conversations are assigned to the responsible class teacher. +- Direct conversations store a five-business-day escalation deadline. +- Students and linked parents may escalate unresolved conversations after the + deadline; the school administrator is then added as a moderator. +- Teachers and administrators can create class-group rooms only for their + authorized school/class context. +- Membership-based message posting is enforced for group rooms. + +## Phase 3: Student And Parent Leave Workflow + +Scope: + +- Generalize `IllnessReport` into a leave request workflow for illness, + personal days, and religious holidays. +- Allow evidence attachment metadata without exposing documents outside the + approval chain. +- Require parent approval for student requests. +- Notify the responsible teacher after parent approval. +- Require administrator approval before the leave becomes calendar-visible. +- Create calendar records for the student, teacher, and school administration. + +Schema work: + +- Add leave type, admin approval, review actor/timestamps, evidence metadata, + and linked calendar event fields to `IllnessReport` or migrate to a dedicated + `StudentLeaveRequest` model. +- Add notification records for each workflow transition. + +Verification: + +- Workflow tests covering pending, parent-approved, teacher-notified, + admin-approved, rejected, and calendar-created states. + +## Phase 4: Teacher Leave And Calendar Visibility + +Scope: + +- Extend `TeacherAbsence` with administrator approval, vice-principal + notification, private administrator notes, and evidence attachments. +- Keep teacher absence notifications hidden from students. +- On approval, publish only relevant calendar events to the teacher, + administrators, and affected class calendars. +- Add explicit calendar audience and owner visibility rules. + +Schema work: + +- Add a `CalendarEventAudience` relation or an audience JSON replacement with + recipient users, class groups, and parent visibility. +- Add `privateAdminNotes` and approval data to `TeacherAbsence`. + +Verification: + +- API tests that prove private notes and unpublished teacher events cannot be + retrieved by students or parents. + +## Phase 5: UI Completion And Regression Coverage + +Scope: + +- Render role-specific menu items and communication/leave screens. +- Add escalation countdown/status to student and parent conversations. +- Add approval work queues for teachers, vice principals, and administrators. +- Add calendar visibility controls for teachers and students. +- Add responsive, accessible group and approval UI states. + +Verification: + +- Role-by-role Playwright journeys. +- API authorization matrix tests for every protected route. +- `pnpm exec tsc --noEmit --pretty false`, `pnpm lint`, `pnpm test`, and + `pnpm build`. + +## Dependency Order + +1. Phase 1 must precede all others because it establishes reusable access + predicates. +2. Phase 2 and Phase 3 can begin in parallel after Phase 1. +3. Phase 4 depends on the calendar audience design from Phase 3. +4. Phase 5 depends on completed APIs from Phases 2 through 4. + +## Rollback + +- 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. diff --git a/prisma/migrations/20260802140000_add_communication_escalation/migration.sql b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql new file mode 100644 index 0000000..f49951f --- /dev/null +++ b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql @@ -0,0 +1,15 @@ +ALTER TABLE "communication_rooms" + ADD COLUMN "audienceType" TEXT NOT NULL DEFAULT 'direct', + ADD COLUMN "classGroupId" TEXT, + ADD COLUMN "escalationEligibleAt" TIMESTAMP(3), + ADD COLUMN "escalatedAt" TIMESTAMP(3), + ADD COLUMN "resolvedAt" TIMESTAMP(3), + ADD COLUMN "resolutionStatus" TEXT NOT NULL DEFAULT 'open'; + +ALTER TABLE "communication_rooms" + ADD CONSTRAINT "communication_rooms_classGroupId_fkey" + FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId"); +CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt"); diff --git a/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql new file mode 100644 index 0000000..81097cb --- /dev/null +++ b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql @@ -0,0 +1,21 @@ +ALTER TABLE "illness_reports" + ADD COLUMN "leaveType" TEXT NOT NULL DEFAULT 'illness', + ADD COLUMN "teacherNotifiedAt" TIMESTAMP(3), + ADD COLUMN "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN "adminApprovedBy" TEXT, + ADD COLUMN "adminApprovedAt" TIMESTAMP(3), + ADD COLUMN "adminNotes" TEXT, + ADD COLUMN "calendarEventId" TEXT; + +ALTER TABLE "illness_reports" + ADD CONSTRAINT "illness_reports_adminApprovedBy_fkey" + FOREIGN KEY ("adminApprovedBy") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "illness_reports" + ADD CONSTRAINT "illness_reports_calendarEventId_fkey" + FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId"); +CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85172fb..ba565f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -198,6 +198,7 @@ model User { chatMessages ChatMessage[] illnessReports IllnessReport[] illnessReportApprovals IllnessReport[] @relation("IllnessApprover") + illnessReportAdminApprovals IllnessReport[] @relation("IllnessAdminApprover") communicationRoomMembers CommunicationRoomMember[] communicationMessages CommunicationMessage[] counselingAppointments CounselingAppointment[] @relation("Counselor") @@ -298,6 +299,7 @@ model ClassGroup { peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentClassGroup") schoolEvents SchoolEvent[] aiTestGenerations AITestGeneration[] + communicationRooms CommunicationRoom[] examPlans ExamPlan[] seatingCharts SeatingChart[] substitutionAssignments SubstitutionAssignment[] @@ -1154,6 +1156,7 @@ model CalendarEvent { parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id]) childEvents CalendarEvent[] @relation("EventRecurrence") examPlan ExamPlan? + illnessReport IllnessReport? @@index([schoolId]) @@index([teacherId]) @@ -2489,8 +2492,14 @@ model CommunicationRoom { studentId String teacherId String roomType String @default("chat") // chat, voice, video + audienceType String @default("direct") // direct, class_group, parent_group, staff + classGroupId String? status String @default("requested") // requested, active, closed requestedBy String // student or teacher + escalationEligibleAt DateTime? + escalatedAt DateTime? + resolvedAt DateTime? + resolutionStatus String @default("open") // open, resolved, unresolved acceptedAt DateTime? closedAt DateTime? closeReason String? @@ -2500,6 +2509,7 @@ model CommunicationRoom { school School @relation(fields: [schoolId], references: [id]) student User @relation("RoomStudent", fields: [studentId], references: [id]) teacher User @relation("RoomTeacher", fields: [teacherId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) members CommunicationRoomMember[] messages CommunicationMessage[] chatMessages ChatMessage[] @@ -2508,6 +2518,8 @@ model CommunicationRoom { @@index([studentId]) @@index([teacherId]) @@index([status]) + @@index([classGroupId]) + @@index([escalationEligibleAt]) @@map("communication_rooms") } diff --git a/src/app/api/classes/[id]/students/route.ts b/src/app/api/classes/[id]/students/route.ts index da0a8bb..df37816 100755 --- a/src/app/api/classes/[id]/students/route.ts +++ b/src/app/api/classes/[id]/students/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 { canAccessClass } from '@/lib/access-policy'; export async function GET( _request: Request, @@ -15,6 +16,10 @@ export async function GET( const { id: classGroupId } = await params; + if (!session.user || !(await canAccessClass(session.user, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const enrollments = await db.enrollment.findMany({ where: { classGroupId, @@ -56,11 +61,7 @@ export async function POST( return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - if ( - session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' - ) { + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/src/app/api/communication-rooms/[id]/messages/route.ts b/src/app/api/communication-rooms/[id]/messages/route.ts index 55fe7b3..0f20e48 100755 --- a/src/app/api/communication-rooms/[id]/messages/route.ts +++ b/src/app/api/communication-rooms/[id]/messages/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessRoom } from '@/lib/communication-policy'; export async function GET( request: Request, @@ -22,22 +23,15 @@ export async function GET( const role = session.user?.role; const userId = session.userId; - // Access control - if (role === 'STUDENT' && room.studentId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } else if (role === 'TEACHER' && room.teacherId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } else if (role === 'PARENT') { + if (role === 'PARENT') { const parentLink = await db.parentStudentLink.findFirst({ - where: { parentId: userId, studentId: room.studentId }, + where: { parentId: userId, student: { userId: room.studentId } }, }); if (!parentLink) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { - if (room.schoolId !== session.user?.schoolId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + } else if (!canAccessRoom(role, room, userId, session.user?.schoolId ?? null)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const { searchParams } = new URL(request.url); @@ -95,12 +89,12 @@ export async function POST( const role = session.user?.role; const userId = session.userId; - // Only student and teacher can send messages (not admin, not parent) - if (role !== 'STUDENT' && role !== 'TEACHER') { - return NextResponse.json({ error: 'Only participants can send messages' }, { status: 403 }); - } - - if (room.studentId !== userId && room.teacherId !== userId) { + const membership = await db.communicationRoomMember.findUnique({ + where: { roomId_userId: { roomId: id, userId } }, + select: { id: true }, + }); + const isSchoolAdministrator = (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') && room.escalatedAt; + if (!membership && !isSchoolAdministrator) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/src/app/api/communication-rooms/[id]/route.ts b/src/app/api/communication-rooms/[id]/route.ts index 4c0b06e..8640e11 100755 --- a/src/app/api/communication-rooms/[id]/route.ts +++ b/src/app/api/communication-rooms/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessRoom, canEscalateToAdmin } from '@/lib/communication-policy'; export async function GET( _request: Request, @@ -31,22 +32,16 @@ export async function GET( const role = session.user?.role; const userId = session.userId; - // Access control - if (role === 'STUDENT' && room.studentId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } else if (role === 'TEACHER' && room.teacherId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } else if (role === 'PARENT') { + // Direct student rooms remain private until explicitly escalated. + if (role === 'PARENT') { const parentLink = await db.parentStudentLink.findFirst({ - where: { parentId: userId, studentId: room.studentId }, + where: { parentId: userId, student: { userId: room.studentId } }, }); if (!parentLink) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { - if (room.schoolId !== session.user?.schoolId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + } else if (!canAccessRoom(role, room, userId, session.user?.schoolId ?? null)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } return NextResponse.json(room); @@ -77,6 +72,31 @@ export async function PUT( const userId = session.userId; const body = await request.json(); + if (body.action === 'escalate') { + if (role !== 'STUDENT' && role !== 'PARENT') { + return NextResponse.json({ error: 'Only students or parents can escalate' }, { status: 403 }); + } + if (role === 'STUDENT' && room.studentId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (role === 'PARENT') { + const student = await db.student.findFirst({ where: { userId: room.studentId }, select: { id: true } }); + const link = student && await db.parentStudentLink.findFirst({ where: { parentId: userId, studentId: student.id }, select: { id: true } }); + if (!link) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!canEscalateToAdmin(room)) { + return NextResponse.json({ error: 'Escalation becomes available after five business days without resolution' }, { status: 400 }); + } + const admin = await db.user.findFirst({ where: { schoolId: room.schoolId, role: 'SCHOOL_ADMIN', deletedAt: null }, select: { id: true } }); + if (!admin) return NextResponse.json({ error: 'No school administrator is assigned' }, { status: 400 }); + await db.communicationRoomMember.upsert({ + where: { roomId_userId: { roomId: id, userId: admin.id } }, + update: { role: 'moderator' }, + create: { roomId: id, userId: admin.id, role: 'moderator' }, + }); + return NextResponse.json(await db.communicationRoom.update({ where: { id }, data: { escalatedAt: new Date(), resolutionStatus: 'unresolved' } })); + } + // Teacher accepts a room if (body.status === 'active' && room.status === 'requested') { if (role !== 'TEACHER' || room.teacherId !== userId) { @@ -112,6 +132,8 @@ export async function PUT( status: 'closed', closedAt: new Date(), closeReason: body.closeReason || null, + resolvedAt: body.resolved ? new Date() : null, + resolutionStatus: body.resolved ? 'resolved' : 'unresolved', }, include: { student: { select: { id: true, firstName: true, lastName: true } }, diff --git a/src/app/api/communication-rooms/route.ts b/src/app/api/communication-rooms/route.ts index 587d896..6b5e802 100755 --- a/src/app/api/communication-rooms/route.ts +++ b/src/app/api/communication-rooms/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canCreateDirectRoom, canCreateGroupRoom, escalationEligibleAt } from '@/lib/communication-policy'; +import { getTeacherClassIds } from '@/lib/access-policy'; export async function GET(request: Request) { try { @@ -76,13 +78,65 @@ export async function POST(request: Request) { const role = session.user?.role; const userId = session.userId; - // Only students can request conversations - if (role !== 'STUDENT') { - return NextResponse.json({ error: 'Only students can request conversations' }, { status: 403 }); + const body = await request.json(); + const { reason, classGroupId, audienceType = 'direct' } = body; + + if (audienceType !== 'direct') { + if (!canCreateGroupRoom(role)) { + return NextResponse.json({ error: 'Only teachers and administrators can create groups' }, { status: 403 }); + } + if (!classGroupId) { + return NextResponse.json({ error: 'classGroupId is required for groups' }, { status: 400 }); + } + + if (role === 'TEACHER') { + const classIds = await getTeacherClassIds(userId); + if (!classIds.includes(classGroupId)) { + return NextResponse.json({ error: 'Teachers can only create groups for their own classes' }, { status: 403 }); + } + } + + const classGroup = await db.classGroup.findUnique({ + where: { id: classGroupId }, + select: { id: true, schoolId: true, responsibleTeacherId: true, enrollments: { where: { endDate: null }, select: { student: { select: { userId: true } } } } }, + }); + if (!classGroup || (session.user?.schoolId && classGroup.schoolId !== session.user.schoolId)) { + return NextResponse.json({ error: 'Class not found' }, { status: 404 }); + } + + const participantIds = classGroup.enrollments.flatMap((enrollment) => enrollment.student.userId ? [enrollment.student.userId] : []); + const moderatorId = classGroup.responsibleTeacherId ?? userId; + const room = await db.communicationRoom.create({ + data: { + schoolId: classGroup.schoolId, + studentId: userId, + teacherId: moderatorId, + roomType: 'chat', + audienceType, + classGroupId, + status: 'active', + requestedBy: userId, + acceptedAt: new Date(), + }, + }); + await db.communicationRoomMember.createMany({ + data: [...new Set([userId, moderatorId, ...participantIds])].map((memberId) => ({ + roomId: room.id, + userId: memberId, + role: memberId === moderatorId ? 'moderator' : 'member', + })), + skipDuplicates: true, + }); + return NextResponse.json(room, { status: 201 }); } - const body = await request.json(); - const { reason } = body; + if (!canCreateDirectRoom(role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + if (role === 'TEACHER') { + return NextResponse.json({ error: 'Teachers should use staff communication channels' }, { status: 400 }); + } // Find the student's class and the responsible teacher const student = await db.student.findFirst({ @@ -147,6 +201,9 @@ export async function POST(request: Request) { roomType: 'chat', status: 'requested', requestedBy: userId, + audienceType: 'direct', + classGroupId: currentEnrollment.classGroup.id, + escalationEligibleAt: escalationEligibleAt(new Date()), }, include: { student: { select: { id: true, firstName: true, lastName: true } }, diff --git a/src/app/api/illness-reports/[id]/route.ts b/src/app/api/illness-reports/[id]/route.ts index 54d9982..c1f7264 100755 --- a/src/app/api/illness-reports/[id]/route.ts +++ b/src/app/api/illness-reports/[id]/route.ts @@ -89,8 +89,8 @@ export async function PUT( const userId = session.userId; const body = await request.json(); - // PARENT can approve/reject pending reports - if (body.parentApprovalStatus && (role === 'PARENT' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL')) { + // Parent approval remains parent-only. Final approval is handled by /approve. + if (body.parentApprovalStatus && role === 'PARENT') { const newStatus = body.parentApprovalStatus; if (newStatus !== 'approved' && newStatus !== 'rejected') { return NextResponse.json({ error: 'Invalid approval status' }, { status: 400 }); @@ -112,8 +112,8 @@ export async function PUT( parentApprovedBy: userId, parentApprovedAt: new Date(), isVisibleToTeacher: newStatus === 'approved', - isVisibleToAdmin: newStatus === 'approved', - status: newStatus === 'approved' ? 'approved' : 'rejected', + isVisibleToAdmin: false, + status: newStatus === 'approved' ? 'pending' : 'rejected', }, include: { student: { select: { id: true, firstName: true, lastName: true } }, diff --git a/src/app/api/illness-reports/approve/route.ts b/src/app/api/illness-reports/approve/route.ts index 0f3bb3c..064436f 100755 --- a/src/app/api/illness-reports/approve/route.ts +++ b/src/app/api/illness-reports/approve/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAdminApprove, canCreateCalendarEvent, canParentApprove } from '@/lib/leave-policy'; export async function POST(request: Request) { try { @@ -12,8 +13,8 @@ export async function POST(request: Request) { const role = session.user?.role; const userId = session.userId; - if (role !== 'PARENT' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { - return NextResponse.json({ error: 'Only parents or admins can approve' }, { status: 403 }); + if (!canParentApprove(role) && !canAdminApprove(role)) { + return NextResponse.json({ error: 'Only parents or administrators can approve leave requests' }, { status: 403 }); } const body = await request.json(); @@ -32,12 +33,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Report not found' }, { status: 404 }); } - if (report.parentApprovalStatus !== 'pending') { - return NextResponse.json({ error: 'Report already processed' }, { status: 400 }); - } - - // Verify parent is linked to this student if (role === 'PARENT') { + if (report.parentApprovalStatus !== 'pending') { + return NextResponse.json({ error: 'Parent approval has already been processed' }, { status: 400 }); + } const parentLink = await db.parentStudentLink.findFirst({ where: { parentId: userId, studentId: report.studentId }, }); @@ -46,25 +45,78 @@ export async function POST(request: Request) { } } - const newStatus = action === 'approve' ? 'approved' : 'rejected'; + const newStatus = action === 'approve' ? 'approved' : 'rejected'; + const updated = await db.illnessReport.update({ + where: { id: reportId }, + data: { + parentApprovalStatus: newStatus, + parentApprovedBy: userId, + parentApprovedAt: new Date(), + isVisibleToTeacher: newStatus === 'approved', + status: newStatus === 'approved' ? 'pending' : 'rejected', + }, + include: { student: { include: { enrollments: { where: { endDate: null }, include: { classGroup: { select: { responsibleTeacherId: true } } }, take: 1 } } } }, + }); + if (newStatus === 'approved') { + const teacherId = updated.student.enrollments[0]?.classGroup.responsibleTeacherId; + if (teacherId) { + await db.notification.create({ + data: { + schoolId: updated.schoolId, + userId: teacherId, + type: 'LEAVE_PARENT_APPROVED', + category: 'administrative', + title: 'Student leave request approved by parent', + message: `${updated.student.firstName} ${updated.student.lastName} has an approved leave request awaiting administration approval.`, + relatedId: updated.id, + }, + }); + await db.illnessReport.update({ where: { id: updated.id }, data: { teacherNotifiedAt: new Date() } }); + } + } + return NextResponse.json(updated); + } + + if (report.parentApprovalStatus !== 'approved' || report.adminApprovalStatus !== 'pending') { + return NextResponse.json({ error: 'This request is not ready for administrator approval' }, { status: 400 }); + } + if (session.user?.schoolId && report.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const adminStatus = action === 'approve' ? 'approved' : 'rejected'; const updated = await db.illnessReport.update({ where: { id: reportId }, data: { - parentApprovalStatus: newStatus, - parentApprovedBy: userId, - parentApprovedAt: new Date(), - isVisibleToTeacher: newStatus === 'approved', - isVisibleToAdmin: newStatus === 'approved', - status: newStatus === 'approved' ? 'approved' : 'rejected', + adminApprovalStatus: adminStatus, + adminApprovedBy: userId, + adminApprovedAt: new Date(), + isVisibleToAdmin: true, + status: adminStatus, }, include: { - student: { select: { id: true, firstName: true, lastName: true } }, - reporter: { select: { id: true, firstName: true, lastName: true } }, - approver: { select: { id: true, firstName: true, lastName: true } }, + student: { include: { enrollments: { where: { endDate: null }, include: { classGroup: true }, take: 1 } } }, }, }); + if (canCreateCalendarEvent(updated)) { + const classGroup = updated.student.enrollments[0]?.classGroup; + const event = await db.calendarEvent.create({ + data: { + schoolId: updated.schoolId, + teacherId: userId, + title: `${updated.student.firstName} ${updated.student.lastName}: approved leave`, + date: updated.startDate, + eventType: 'leave', + classGroupId: classGroup?.id ?? null, + notes: `Leave type: ${updated.leaveType}`, + allDay: true, + }, + }); + await db.illnessReport.update({ where: { id: updated.id }, data: { calendarEventId: event.id } }); + } + return NextResponse.json(updated); } catch (error) { console.error('IllnessReport approve error:', error); diff --git a/src/app/api/illness-reports/route.ts b/src/app/api/illness-reports/route.ts index 2b5ba27..e315bfe 100755 --- a/src/app/api/illness-reports/route.ts +++ b/src/app/api/illness-reports/route.ts @@ -99,7 +99,7 @@ export async function POST(request: Request) { } const body = await request.json(); - const { studentId, reason, description, startDate, endDate, documentUrl } = body; + const { studentId, reason, description, startDate, endDate, documentUrl, leaveType = 'illness' } = body; if (!studentId || !reason || !startDate) { return NextResponse.json( @@ -141,8 +141,8 @@ export async function POST(request: Request) { const reporterType = role === 'STUDENT' ? 'student' : 'parent'; const isParentReport = reporterType === 'parent'; - // When student reports: needs parent approval, not visible to teacher/admin - // When parent reports: auto-approved, visible to teacher/admin + // Both student and parent requests require the final administrator approval. + // Parent-submitted requests satisfy the parent approval step immediately. const report = await db.illnessReport.create({ data: { schoolId: student.schoolId, @@ -154,12 +154,14 @@ export async function POST(request: Request) { startDate: new Date(startDate), endDate: endDate ? new Date(endDate) : null, documentUrl: documentUrl || null, + leaveType, parentApprovalStatus: isParentReport ? 'approved' : 'pending', parentApprovedBy: isParentReport ? userId : null, parentApprovedAt: isParentReport ? new Date() : null, isVisibleToTeacher: isParentReport, - isVisibleToAdmin: isParentReport, - status: isParentReport ? 'approved' : 'pending', + isVisibleToAdmin: false, + adminApprovalStatus: 'pending', + status: 'pending', }, include: { student: { select: { id: true, firstName: true, lastName: true } }, diff --git a/src/app/api/students/[id]/details/route.ts b/src/app/api/students/[id]/details/route.ts index 1154163..e461064 100755 --- a/src/app/api/students/[id]/details/route.ts +++ b/src/app/api/students/[id]/details/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; export async function GET( _request: Request, @@ -14,6 +15,10 @@ export async function GET( const { id: studentId } = await params; + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Load the student with relations const student = await db.student.findUnique({ where: { id: studentId, deletedAt: null }, diff --git a/src/app/api/students/route.ts b/src/app/api/students/route.ts index 7708b0e..5973ed6 100755 --- a/src/app/api/students/route.ts +++ b/src/app/api/students/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 { canAccessStudent, getTeacherClassIds } from '@/lib/access-policy'; +import { canManageStudent, isAdministrator } from '@/lib/role-access'; const createStudentSchema = z.object({ schoolId: z.string().min(1), @@ -27,10 +29,26 @@ export async function GET(request: Request) { const search = searchParams.get('search'); const userId = searchParams.get('userId'); + const user = session.user; + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + const where: Record = { deletedAt: null }; if (schoolId) where.schoolId = schoolId; + if (!isAdministrator(user.role)) { + if (user.role === 'TEACHER') { + const classIds = await getTeacherClassIds(user.id); + where.enrollments = { some: { classGroupId: { in: classIds }, endDate: null } }; + } else if (user.role === 'STUDENT') { + where.userId = user.id; + } else if (user.role === 'PARENT') { + where.parentStudentLinks = { some: { parentId: user.id } }; + } else { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + // Filter by userId (direct link to User account) let userIdFilter: Record[] | null = null; if (userId) { @@ -112,11 +130,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - if ( - session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' - ) { + if (!canManageStudent(session.user?.role)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -129,6 +143,13 @@ export async function POST(request: Request) { ); } + if ( + session.user?.role === 'TEACHER' && + (!session.user.schoolId || parsed.data.schoolId !== session.user.schoolId) + ) { + return NextResponse.json({ error: 'Teachers can only create students in their own school' }, { status: 403 }); + } + const student = await db.student.create({ data: parsed.data, include: { @@ -153,11 +174,7 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - if ( - session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' - ) { + if (!canManageStudent(session.user?.role)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -185,6 +202,10 @@ export async function PUT(request: Request) { const { id, ...updateData } = parsed.data; + if (!session.user || !(await canAccessStudent(session.user, id))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const student = await db.student.update({ where: { id }, data: updateData, diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 3321566..0905950 100755 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/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 { canManageUser } from '@/lib/role-access'; const updateUserSchema = z.object({ firstName: z.string().min(1).optional(), @@ -56,6 +57,10 @@ export async function GET( return NextResponse.json({ error: 'User not found' }, { status: 404 }); } + if (!canManageUser(session.user?.role, user.role, user.id === session.userId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // School admins can only access users from their own school if ( session.user?.role === 'SCHOOL_ADMIN' && @@ -108,6 +113,10 @@ export async function PUT( return NextResponse.json({ error: 'User not found' }, { status: 404 }); } + if (!canManageUser(session.user?.role, existing.role, id === session.userId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // School admins can only edit users from their own school if ( session.user?.role === 'SCHOOL_ADMIN' && @@ -210,6 +219,10 @@ export async function DELETE( return NextResponse.json({ error: 'User not found' }, { status: 404 }); } + if (!canManageUser(session.user?.role, existing.role, id === session.userId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // School admins can only delete users from their own school if ( session.user?.role === 'SCHOOL_ADMIN' && diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index d83dd89..1275b0f 100755 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession, hashPassword } from '@/lib/auth'; +import { isAdministrator } from '@/lib/role-access'; const createUserSchema = z.object({ schoolId: z.string().nullable().optional(), @@ -31,8 +32,7 @@ export async function GET(request: Request) { if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' + session.user?.role !== 'SCHOOL_ADMIN' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -113,8 +113,7 @@ export async function POST(request: Request) { if (body.action === 'bulkCreateStudents') { if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' + session.user?.role !== 'SCHOOL_ADMIN' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -212,8 +211,7 @@ export async function POST(request: Request) { // ── Single user creation ── if ( session.user?.role !== 'SUPER_ADMIN' && - session.user?.role !== 'SCHOOL_ADMIN' && - session.user?.role !== 'TEACHER' + session.user?.role !== 'SCHOOL_ADMIN' ) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -228,7 +226,7 @@ export async function POST(request: Request) { const { password, studentId, ...userData } = parsed.data; - // School admins can only create users in their own school + // Administrators can only create users in their own school. if ( session.user?.role === 'SCHOOL_ADMIN' && session.user?.schoolId && @@ -242,10 +240,7 @@ export async function POST(request: Request) { } // School admins cannot create super admin accounts - if ( - session.user?.role === 'SCHOOL_ADMIN' && - userData.role === 'SUPER_ADMIN' - ) { + if (session.user?.role === 'SCHOOL_ADMIN' && userData.role === 'SUPER_ADMIN') { return NextResponse.json( { error: 'Cannot create super admin accounts' }, { status: 403 } diff --git a/src/lib/access-policy.ts b/src/lib/access-policy.ts new file mode 100644 index 0000000..eb6d08f --- /dev/null +++ b/src/lib/access-policy.ts @@ -0,0 +1,75 @@ +import { db } from '@/lib/db'; +import type { SessionUser } from '@/lib/auth'; +import { isAdministrator } from '@/lib/role-access'; + +export async function getTeacherClassIds(userId: string): Promise { + const classes = await db.classGroup.findMany({ + where: { + OR: [ + { responsibleTeacherId: userId }, + { teachers: { some: { userId } } }, + ], + }, + select: { id: true }, + }); + + return classes.map((classGroup) => classGroup.id); +} + +export async function canAccessStudent(user: SessionUser, studentId: string): Promise { + if (isAdministrator(user.role)) return true; + + const student = await db.student.findFirst({ + where: { id: studentId, deletedAt: null }, + select: { id: true, userId: true, schoolId: true }, + }); + + if (!student || (user.schoolId && student.schoolId !== user.schoolId)) return false; + if (user.role === 'STUDENT') return student.userId === user.id; + + if (user.role === 'PARENT') { + return Boolean( + await db.parentStudentLink.findFirst({ + where: { parentId: user.id, studentId, schoolId: student.schoolId }, + select: { id: true }, + }) + ); + } + + if (user.role === 'TEACHER') { + const classIds = await getTeacherClassIds(user.id); + return Boolean( + classIds.length && await db.enrollment.findFirst({ + where: { studentId, classGroupId: { in: classIds }, endDate: null }, + select: { id: true }, + }) + ); + } + + return false; +} + +export async function canAccessClass(user: SessionUser, classGroupId: string): Promise { + if (isAdministrator(user.role)) return true; + + if (user.role === 'TEACHER') { + const classIds = await getTeacherClassIds(user.id); + return classIds.includes(classGroupId); + } + + if (user.role === 'STUDENT') { + return Boolean(await db.enrollment.findFirst({ + where: { classGroupId, student: { userId: user.id }, endDate: null }, + select: { id: true }, + })); + } + + if (user.role === 'PARENT') { + return Boolean(await db.enrollment.findFirst({ + where: { classGroupId, endDate: null, student: { parentStudentLinks: { some: { parentId: user.id } } } }, + select: { id: true }, + })); + } + + return false; +} diff --git a/src/lib/communication-policy.test.ts b/src/lib/communication-policy.test.ts new file mode 100644 index 0000000..97ad9f2 --- /dev/null +++ b/src/lib/communication-policy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { + addBusinessDays, + canCreateGroupRoom, + canAccessRoom, + canEscalateToAdmin, + canMessageAdministrators, +} from './communication-policy'; + +describe('communication policy', () => { + test('counts five business days without weekends', () => { + const friday = new Date('2026-07-31T10:00:00Z'); + expect(addBusinessDays(friday, 5).toISOString().slice(0, 10)).toBe('2026-08-07'); + }); + + test('only enables escalation after the deadline without a resolution', () => { + const now = new Date('2026-08-10T10:00:00Z'); + expect(canEscalateToAdmin({ audienceType: 'direct', resolutionStatus: 'open', escalationEligibleAt: new Date('2026-08-07T10:00:00Z'), escalatedAt: null }, now)).toBeTrue(); + expect(canEscalateToAdmin({ audienceType: 'direct', resolutionStatus: 'resolved', escalationEligibleAt: new Date('2026-08-07T10:00:00Z'), escalatedAt: null }, now)).toBeFalse(); + }); + + test('limits groups and direct administrator messaging to staff', () => { + expect(canCreateGroupRoom('TEACHER')).toBeTrue(); + expect(canCreateGroupRoom('PARENT')).toBeFalse(); + expect(canMessageAdministrators('TEACHER')).toBeTrue(); + expect(canMessageAdministrators('STUDENT')).toBeFalse(); + }); + + test('only exposes escalated student rooms to school administrators', () => { + const room = { schoolId: 'school-1', studentId: 'student-1', teacherId: 'teacher-1', escalatedAt: null }; + expect(canAccessRoom('SCHOOL_ADMIN', room, 'admin-1', 'school-1')).toBeFalse(); + expect(canAccessRoom('TEACHER', room, 'teacher-1', 'school-1')).toBeTrue(); + expect(canAccessRoom('STUDENT', room, 'student-1', 'school-1')).toBeTrue(); + }); +}); diff --git a/src/lib/communication-policy.ts b/src/lib/communication-policy.ts new file mode 100644 index 0000000..5283b95 --- /dev/null +++ b/src/lib/communication-policy.ts @@ -0,0 +1,56 @@ +import type { AppRole } from '@/lib/role-access'; + +const ADMIN_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN']; + +export function addBusinessDays(from: Date, days: number): Date { + const result = new Date(from); + let remaining = days; + + while (remaining > 0) { + result.setDate(result.getDate() + 1); + const day = result.getDay(); + if (day !== 0 && day !== 6) remaining -= 1; + } + + return result; +} + +export function escalationEligibleAt(createdAt: Date): Date { + return addBusinessDays(createdAt, 5); +} + +export function canEscalateToAdmin(room: { + audienceType: string; + resolutionStatus: string; + escalationEligibleAt: Date | null; + escalatedAt: Date | null; +}, now = new Date()): boolean { + return room.audienceType === 'direct' + && room.resolutionStatus !== 'resolved' + && !room.escalatedAt + && Boolean(room.escalationEligibleAt && room.escalationEligibleAt <= now); +} + +export function canCreateDirectRoom(role: string | undefined): boolean { + return role === 'STUDENT' || role === 'PARENT' || role === 'TEACHER'; +} + +export function canCreateGroupRoom(role: string | undefined): boolean { + return role === 'TEACHER' || ADMIN_ROLES.includes(role as AppRole); +} + +export function canMessageAdministrators(role: string | undefined): boolean { + return role === 'TEACHER' || ADMIN_ROLES.includes(role as AppRole); +} + +export function canAccessRoom( + role: string | undefined, + room: { schoolId: string; studentId: string; teacherId: string; escalatedAt: Date | null }, + userId: string, + userSchoolId: string | null +): boolean { + if (role === 'SUPER_ADMIN') return true; + if (role === 'SCHOOL_ADMIN') return room.schoolId === userSchoolId && Boolean(room.escalatedAt); + if (role === 'VICE_PRINCIPAL') return room.schoolId === userSchoolId && room.teacherId === userId; + return room.studentId === userId || room.teacherId === userId; +} diff --git a/src/lib/leave-policy.test.ts b/src/lib/leave-policy.test.ts new file mode 100644 index 0000000..88e968d --- /dev/null +++ b/src/lib/leave-policy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { canAdminApprove, canCreateCalendarEvent, getLeaveState } from './leave-policy'; + +describe('leave policy', () => { + test('requires parent then administrator approval', () => { + expect(getLeaveState({ parentApprovalStatus: 'pending', adminApprovalStatus: 'pending' })).toBe('awaiting_parent_approval'); + expect(getLeaveState({ parentApprovalStatus: 'approved', adminApprovalStatus: 'pending' })).toBe('awaiting_admin_approval'); + expect(getLeaveState({ parentApprovalStatus: 'approved', adminApprovalStatus: 'approved' })).toBe('approved'); + }); + + test('only administrators can give the final approval', () => { + expect(canAdminApprove('SCHOOL_ADMIN')).toBeTrue(); + expect(canAdminApprove('VICE_PRINCIPAL')).toBeFalse(); + }); + + test('creates one calendar event after full approval', () => { + expect(canCreateCalendarEvent({ parentApprovalStatus: 'approved', adminApprovalStatus: 'approved', calendarEventId: null })).toBeTrue(); + expect(canCreateCalendarEvent({ parentApprovalStatus: 'approved', adminApprovalStatus: 'pending', calendarEventId: null })).toBeFalse(); + }); +}); diff --git a/src/lib/leave-policy.ts b/src/lib/leave-policy.ts new file mode 100644 index 0000000..db4c667 --- /dev/null +++ b/src/lib/leave-policy.ts @@ -0,0 +1,35 @@ +export type LeaveState = + | 'awaiting_parent_approval' + | 'awaiting_admin_approval' + | 'approved' + | 'rejected'; + +export function getLeaveState(report: { + parentApprovalStatus: string; + adminApprovalStatus: string; +}): LeaveState { + if (report.parentApprovalStatus === 'rejected' || report.adminApprovalStatus === 'rejected') { + return 'rejected'; + } + if (report.parentApprovalStatus !== 'approved') return 'awaiting_parent_approval'; + if (report.adminApprovalStatus !== 'approved') return 'awaiting_admin_approval'; + return 'approved'; +} + +export function canParentApprove(role: string | undefined): boolean { + return role === 'PARENT'; +} + +export function canAdminApprove(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export function canCreateCalendarEvent(report: { + parentApprovalStatus: string; + adminApprovalStatus: string; + calendarEventId: string | null; +}): boolean { + return report.parentApprovalStatus === 'approved' + && report.adminApprovalStatus === 'approved' + && !report.calendarEventId; +} diff --git a/src/lib/role-access.test.ts b/src/lib/role-access.test.ts index afa3066..bc38295 100644 --- a/src/lib/role-access.test.ts +++ b/src/lib/role-access.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from 'bun:test'; import type { ViewName } from './store'; -import { canAccessView, filterSectionsForRole, filterViewsForRole, getFallbackView } from './role-access'; +import { + canAccessView, + canManageStudent, + canManageUser, + filterSectionsForRole, + filterViewsForRole, + getFallbackView, +} from './role-access'; const mixedItems = [ { key: 'dashboard' as const }, @@ -35,4 +42,25 @@ describe('role access', () => { expect(canAccessView('SUPER_ADMIN', view)).toBeTrue(); } }); + + test('limits student and parent menus to self-service views', () => { + expect(canAccessView('STUDENT', 'classes')).toBeFalse(); + expect(canAccessView('STUDENT', 'student-portal')).toBeTrue(); + expect(canAccessView('PARENT', 'parents')).toBeFalse(); + expect(canAccessView('PARENT', 'parent-portal')).toBeTrue(); + }); + + 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(); + }); + + test('allows student record mutation only to administrators and teachers', () => { + expect(canManageStudent('SCHOOL_ADMIN')).toBeTrue(); + expect(canManageStudent('TEACHER')).toBeTrue(); + expect(canManageStudent('VICE_PRINCIPAL')).toBeFalse(); + expect(canManageStudent('PARENT')).toBeFalse(); + }); }); diff --git a/src/lib/role-access.ts b/src/lib/role-access.ts index 9ed47ad..2530787 100644 --- a/src/lib/role-access.ts +++ b/src/lib/role-access.ts @@ -2,29 +2,26 @@ import type { ViewName } from '@/lib/store'; export type AppRole = 'PARENT' | 'STUDENT' | 'TEACHER' | 'VICE_PRINCIPAL' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'DPO'; +export const ADMIN_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN']; +export const USER_MANAGER_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN']; +export const STUDENT_MANAGER_ROLES: readonly AppRole[] = ['SCHOOL_ADMIN', 'SUPER_ADMIN', 'TEACHER']; + const parentViews: ViewName[] = [ - 'dashboard', 'parent-portal', 'parents', 'grading', 'attendance', 'calendar', - 'communication', 'illness', 'report-cards', 'school-events', 'school-newsletter', - 'notification-center', 'announcements', 'settings', + 'dashboard', 'parent-portal', 'calendar', 'communication', 'illness', + 'notification-center', 'announcements', ]; const studentViews: ViewName[] = [ - 'dashboard', 'student-portal', 'notebooks', 'flower', 'grading', 'grade-analytics', - 'homework', 'attendance', 'portfolio', 'calendar', 'competitions', 'subjects', - 'illness', 'communication', 'counseling', 'ai-tests', 'exam-calendar', - 'peer-assessment', 'report-cards', 'student-achievements', 'student-study-planner', - 'student-wellness', 'student-career', 'resources', 'school-library', 'school-events', - 'school-newsletter', 'school-transport', 'notification-center', 'announcements', 'settings', + 'dashboard', 'student-portal', 'calendar', 'communication', 'illness', + 'notification-center', ]; const teacherViews: ViewName[] = [ 'dashboard', 'classes', 'competencies', 'progress', 'flower', 'assessments', 'grading', - 'reports', 'settings', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans', - 'parents', 'behavior', 'coverage', 'rubrics', 'comments', 'notebooks', 'drawing', 'homework', - 'portfolio', 'timetable', 'resources', 'competitions', 'subjects', 'illness', 'communication', - 'counseling', 'ai-tests', 'notification-center', 'announcements', 'tablet-grading', - 'exam-calendar', 'peer-assessment', 'seating-chart', 'school-library', 'report-cards', - 'student-wellness', 'student-career', 'school-events', 'school-newsletter', + 'reports', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans', + 'behavior', 'coverage', 'rubrics', 'comments', 'homework', 'timetable', 'resources', + 'illness', 'communication', 'notification-center', 'tablet-grading', 'exam-calendar', + 'seating-chart', 'report-cards', ]; const vicePrincipalViews: ViewName[] = [ @@ -36,6 +33,21 @@ export function hasRoleAccess(role: string | undefined, allowedRoles: readonly A return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || (role !== undefined && allowedRoles.includes(role as AppRole)); } +export function isAdministrator(role: string | undefined): boolean { + return role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +export function canManageUser(actorRole: string | undefined, targetRole: string, isSelf = false): boolean { + if (!isAdministrator(actorRole)) return false; + if (isSelf) return false; + if (actorRole === 'SCHOOL_ADMIN' && targetRole === 'SUPER_ADMIN') return false; + return true; +} + +export function canManageStudent(actorRole: string | undefined): boolean { + return STUDENT_MANAGER_ROLES.includes(actorRole as AppRole); +} + export function canAccessView(role: string | undefined, view: ViewName): boolean { if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') return true; const allowed = role === 'PARENT' ? parentViews