Add 8 files, update 14 files

This commit is contained in:
[dyad] committed 2026-08-02 14:00:21 +02:00
1 parent 669e029f45
commit c84b080fb8
22 files changed
+727 -96

No files matched your search

+160
View File
@@ -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.
@@ -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");
@@ -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");
+12
View File
@@ -198,6 +198,7 @@ model User {
chatMessages ChatMessage[] chatMessages ChatMessage[]
illnessReports IllnessReport[] illnessReports IllnessReport[]
illnessReportApprovals IllnessReport[] @relation("IllnessApprover") illnessReportApprovals IllnessReport[] @relation("IllnessApprover")
illnessReportAdminApprovals IllnessReport[] @relation("IllnessAdminApprover")
communicationRoomMembers CommunicationRoomMember[] communicationRoomMembers CommunicationRoomMember[]
communicationMessages CommunicationMessage[] communicationMessages CommunicationMessage[]
counselingAppointments CounselingAppointment[] @relation("Counselor") counselingAppointments CounselingAppointment[] @relation("Counselor")
@@ -298,6 +299,7 @@ model ClassGroup {
peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentClassGroup") peerAssessmentSessions PeerAssessmentSession[] @relation("PeerAssessmentClassGroup")
schoolEvents SchoolEvent[] schoolEvents SchoolEvent[]
aiTestGenerations AITestGeneration[] aiTestGenerations AITestGeneration[]
communicationRooms CommunicationRoom[]
examPlans ExamPlan[] examPlans ExamPlan[]
seatingCharts SeatingChart[] seatingCharts SeatingChart[]
substitutionAssignments SubstitutionAssignment[] substitutionAssignments SubstitutionAssignment[]
@@ -1154,6 +1156,7 @@ model CalendarEvent {
parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id]) parentEvent CalendarEvent? @relation("EventRecurrence", fields: [parentEventId], references: [id])
childEvents CalendarEvent[] @relation("EventRecurrence") childEvents CalendarEvent[] @relation("EventRecurrence")
examPlan ExamPlan? examPlan ExamPlan?
illnessReport IllnessReport?
@@index([schoolId]) @@index([schoolId])
@@index([teacherId]) @@index([teacherId])
@@ -2489,8 +2492,14 @@ model CommunicationRoom {
studentId String studentId String
teacherId String teacherId String
roomType String @default("chat") // chat, voice, video 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 status String @default("requested") // requested, active, closed
requestedBy String // student or teacher requestedBy String // student or teacher
escalationEligibleAt DateTime?
escalatedAt DateTime?
resolvedAt DateTime?
resolutionStatus String @default("open") // open, resolved, unresolved
acceptedAt DateTime? acceptedAt DateTime?
closedAt DateTime? closedAt DateTime?
closeReason String? closeReason String?
@@ -2500,6 +2509,7 @@ model CommunicationRoom {
school School @relation(fields: [schoolId], references: [id]) school School @relation(fields: [schoolId], references: [id])
student User @relation("RoomStudent", fields: [studentId], references: [id]) student User @relation("RoomStudent", fields: [studentId], references: [id])
teacher User @relation("RoomTeacher", fields: [teacherId], references: [id]) teacher User @relation("RoomTeacher", fields: [teacherId], references: [id])
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
members CommunicationRoomMember[] members CommunicationRoomMember[]
messages CommunicationMessage[] messages CommunicationMessage[]
chatMessages ChatMessage[] chatMessages ChatMessage[]
@@ -2508,6 +2518,8 @@ model CommunicationRoom {
@@index([studentId]) @@index([studentId])
@@index([teacherId]) @@index([teacherId])
@@index([status]) @@index([status])
@@index([classGroupId])
@@index([escalationEligibleAt])
@@map("communication_rooms") @@map("communication_rooms")
} }
+6 -5
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAccessClass } from '@/lib/access-policy';
export async function GET( export async function GET(
_request: Request, _request: Request,
@@ -15,6 +16,10 @@ export async function GET(
const { id: classGroupId } = await params; 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({ const enrollments = await db.enrollment.findMany({
where: { where: {
classGroupId, classGroupId,
@@ -56,11 +61,7 @@ export async function POST(
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
if ( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN') {
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAccessRoom } from '@/lib/communication-policy';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -22,23 +23,16 @@ export async function GET(
const role = session.user?.role; const role = session.user?.role;
const userId = session.userId; const userId = session.userId;
// Access control if (role === 'PARENT') {
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') {
const parentLink = await db.parentStudentLink.findFirst({ const parentLink = await db.parentStudentLink.findFirst({
where: { parentId: userId, studentId: room.studentId }, where: { parentId: userId, student: { userId: room.studentId } },
}); });
if (!parentLink) { if (!parentLink) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
} else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { } else if (!canAccessRoom(role, room, userId, session.user?.schoolId ?? null)) {
if (room.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1'); const page = parseInt(searchParams.get('page') || '1');
@@ -95,12 +89,12 @@ export async function POST(
const role = session.user?.role; const role = session.user?.role;
const userId = session.userId; const userId = session.userId;
// Only student and teacher can send messages (not admin, not parent) const membership = await db.communicationRoomMember.findUnique({
if (role !== 'STUDENT' && role !== 'TEACHER') { where: { roomId_userId: { roomId: id, userId } },
return NextResponse.json({ error: 'Only participants can send messages' }, { status: 403 }); select: { id: true },
} });
const isSchoolAdministrator = (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') && room.escalatedAt;
if (room.studentId !== userId && room.teacherId !== userId) { if (!membership && !isSchoolAdministrator) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
+32 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAccessRoom, canEscalateToAdmin } from '@/lib/communication-policy';
export async function GET( export async function GET(
_request: Request, _request: Request,
@@ -31,23 +32,17 @@ export async function GET(
const role = session.user?.role; const role = session.user?.role;
const userId = session.userId; const userId = session.userId;
// Access control // Direct student rooms remain private until explicitly escalated.
if (role === 'STUDENT' && room.studentId !== userId) { if (role === 'PARENT') {
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') {
const parentLink = await db.parentStudentLink.findFirst({ const parentLink = await db.parentStudentLink.findFirst({
where: { parentId: userId, studentId: room.studentId }, where: { parentId: userId, student: { userId: room.studentId } },
}); });
if (!parentLink) { if (!parentLink) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
} else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { } else if (!canAccessRoom(role, room, userId, session.user?.schoolId ?? null)) {
if (room.schoolId !== session.user?.schoolId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
}
return NextResponse.json(room); return NextResponse.json(room);
} catch (error) { } catch (error) {
@@ -77,6 +72,31 @@ export async function PUT(
const userId = session.userId; const userId = session.userId;
const body = await request.json(); 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 // Teacher accepts a room
if (body.status === 'active' && room.status === 'requested') { if (body.status === 'active' && room.status === 'requested') {
if (role !== 'TEACHER' || room.teacherId !== userId) { if (role !== 'TEACHER' || room.teacherId !== userId) {
@@ -112,6 +132,8 @@ export async function PUT(
status: 'closed', status: 'closed',
closedAt: new Date(), closedAt: new Date(),
closeReason: body.closeReason || null, closeReason: body.closeReason || null,
resolvedAt: body.resolved ? new Date() : null,
resolutionStatus: body.resolved ? 'resolved' : 'unresolved',
}, },
include: { include: {
student: { select: { id: true, firstName: true, lastName: true } }, student: { select: { id: true, firstName: true, lastName: true } },
+62 -5
View File
@@ -2,6 +2,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; 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) { export async function GET(request: Request) {
try { try {
@@ -76,13 +78,65 @@ export async function POST(request: Request) {
const role = session.user?.role; const role = session.user?.role;
const userId = session.userId; const userId = session.userId;
// Only students can request conversations const body = await request.json();
if (role !== 'STUDENT') { const { reason, classGroupId, audienceType = 'direct' } = body;
return NextResponse.json({ error: 'Only students can request conversations' }, { status: 403 });
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 });
} }
const body = await request.json(); if (role === 'TEACHER') {
const { reason } = body; 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 });
}
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 // Find the student's class and the responsible teacher
const student = await db.student.findFirst({ const student = await db.student.findFirst({
@@ -147,6 +201,9 @@ export async function POST(request: Request) {
roomType: 'chat', roomType: 'chat',
status: 'requested', status: 'requested',
requestedBy: userId, requestedBy: userId,
audienceType: 'direct',
classGroupId: currentEnrollment.classGroup.id,
escalationEligibleAt: escalationEligibleAt(new Date()),
}, },
include: { include: {
student: { select: { id: true, firstName: true, lastName: true } }, student: { select: { id: true, firstName: true, lastName: true } },
+4 -4
View File
@@ -89,8 +89,8 @@ export async function PUT(
const userId = session.userId; const userId = session.userId;
const body = await request.json(); const body = await request.json();
// PARENT can approve/reject pending reports // Parent approval remains parent-only. Final approval is handled by /approve.
if (body.parentApprovalStatus && (role === 'PARENT' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL')) { if (body.parentApprovalStatus && role === 'PARENT') {
const newStatus = body.parentApprovalStatus; const newStatus = body.parentApprovalStatus;
if (newStatus !== 'approved' && newStatus !== 'rejected') { if (newStatus !== 'approved' && newStatus !== 'rejected') {
return NextResponse.json({ error: 'Invalid approval status' }, { status: 400 }); return NextResponse.json({ error: 'Invalid approval status' }, { status: 400 });
@@ -112,8 +112,8 @@ export async function PUT(
parentApprovedBy: userId, parentApprovedBy: userId,
parentApprovedAt: new Date(), parentApprovedAt: new Date(),
isVisibleToTeacher: newStatus === 'approved', isVisibleToTeacher: newStatus === 'approved',
isVisibleToAdmin: newStatus === 'approved', isVisibleToAdmin: false,
status: newStatus === 'approved' ? 'approved' : 'rejected', status: newStatus === 'approved' ? 'pending' : 'rejected',
}, },
include: { include: {
student: { select: { id: true, firstName: true, lastName: true } }, student: { select: { id: true, firstName: true, lastName: true } },
+66 -14
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAdminApprove, canCreateCalendarEvent, canParentApprove } from '@/lib/leave-policy';
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -12,8 +13,8 @@ export async function POST(request: Request) {
const role = session.user?.role; const role = session.user?.role;
const userId = session.userId; const userId = session.userId;
if (role !== 'PARENT' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') { if (!canParentApprove(role) && !canAdminApprove(role)) {
return NextResponse.json({ error: 'Only parents or admins can approve' }, { status: 403 }); return NextResponse.json({ error: 'Only parents or administrators can approve leave requests' }, { status: 403 });
} }
const body = await request.json(); const body = await request.json();
@@ -32,12 +33,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Report not found' }, { status: 404 }); 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 (role === 'PARENT') {
if (report.parentApprovalStatus !== 'pending') {
return NextResponse.json({ error: 'Parent approval has already been processed' }, { status: 400 });
}
const parentLink = await db.parentStudentLink.findFirst({ const parentLink = await db.parentStudentLink.findFirst({
where: { parentId: userId, studentId: report.studentId }, where: { parentId: userId, studentId: report.studentId },
}); });
@@ -47,7 +46,6 @@ export async function POST(request: Request) {
} }
const newStatus = action === 'approve' ? 'approved' : 'rejected'; const newStatus = action === 'approve' ? 'approved' : 'rejected';
const updated = await db.illnessReport.update({ const updated = await db.illnessReport.update({
where: { id: reportId }, where: { id: reportId },
data: { data: {
@@ -55,15 +53,69 @@ export async function POST(request: Request) {
parentApprovedBy: userId, parentApprovedBy: userId,
parentApprovedAt: new Date(), parentApprovedAt: new Date(),
isVisibleToTeacher: newStatus === 'approved', isVisibleToTeacher: newStatus === 'approved',
isVisibleToAdmin: newStatus === 'approved', status: newStatus === 'approved' ? 'pending' : 'rejected',
status: newStatus === 'approved' ? 'approved' : 'rejected',
}, },
include: { include: { student: { include: { enrollments: { where: { endDate: null }, include: { classGroup: { select: { responsibleTeacherId: true } } }, take: 1 } } } },
student: { select: { id: true, firstName: true, lastName: true } }, });
reporter: { select: { id: true, firstName: true, lastName: true } },
approver: { select: { id: true, firstName: true, lastName: true } }, 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: {
adminApprovalStatus: adminStatus,
adminApprovedBy: userId,
adminApprovedAt: new Date(),
isVisibleToAdmin: true,
status: adminStatus,
},
include: {
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); return NextResponse.json(updated);
} catch (error) { } catch (error) {
+7 -5
View File
@@ -99,7 +99,7 @@ export async function POST(request: Request) {
} }
const body = await request.json(); 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) { if (!studentId || !reason || !startDate) {
return NextResponse.json( return NextResponse.json(
@@ -141,8 +141,8 @@ export async function POST(request: Request) {
const reporterType = role === 'STUDENT' ? 'student' : 'parent'; const reporterType = role === 'STUDENT' ? 'student' : 'parent';
const isParentReport = reporterType === 'parent'; const isParentReport = reporterType === 'parent';
// When student reports: needs parent approval, not visible to teacher/admin // Both student and parent requests require the final administrator approval.
// When parent reports: auto-approved, visible to teacher/admin // Parent-submitted requests satisfy the parent approval step immediately.
const report = await db.illnessReport.create({ const report = await db.illnessReport.create({
data: { data: {
schoolId: student.schoolId, schoolId: student.schoolId,
@@ -154,12 +154,14 @@ export async function POST(request: Request) {
startDate: new Date(startDate), startDate: new Date(startDate),
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
documentUrl: documentUrl || null, documentUrl: documentUrl || null,
leaveType,
parentApprovalStatus: isParentReport ? 'approved' : 'pending', parentApprovalStatus: isParentReport ? 'approved' : 'pending',
parentApprovedBy: isParentReport ? userId : null, parentApprovedBy: isParentReport ? userId : null,
parentApprovedAt: isParentReport ? new Date() : null, parentApprovedAt: isParentReport ? new Date() : null,
isVisibleToTeacher: isParentReport, isVisibleToTeacher: isParentReport,
isVisibleToAdmin: isParentReport, isVisibleToAdmin: false,
status: isParentReport ? 'approved' : 'pending', adminApprovalStatus: 'pending',
status: 'pending',
}, },
include: { include: {
student: { select: { id: true, firstName: true, lastName: true } }, student: { select: { id: true, firstName: true, lastName: true } },
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAccessStudent } from '@/lib/access-policy';
export async function GET( export async function GET(
_request: Request, _request: Request,
@@ -14,6 +15,10 @@ export async function GET(
const { id: studentId } = await params; 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 // Load the student with relations
const student = await db.student.findUnique({ const student = await db.student.findUnique({
where: { id: studentId, deletedAt: null }, where: { id: studentId, deletedAt: null },
+31 -10
View File
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canAccessStudent, getTeacherClassIds } from '@/lib/access-policy';
import { canManageStudent, isAdministrator } from '@/lib/role-access';
const createStudentSchema = z.object({ const createStudentSchema = z.object({
schoolId: z.string().min(1), schoolId: z.string().min(1),
@@ -27,10 +29,26 @@ export async function GET(request: Request) {
const search = searchParams.get('search'); const search = searchParams.get('search');
const userId = searchParams.get('userId'); const userId = searchParams.get('userId');
const user = session.user;
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const where: Record<string, unknown> = { deletedAt: null }; const where: Record<string, unknown> = { deletedAt: null };
if (schoolId) where.schoolId = schoolId; 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) // Filter by userId (direct link to User account)
let userIdFilter: Record<string, unknown>[] | null = null; let userIdFilter: Record<string, unknown>[] | null = null;
if (userId) { if (userId) {
@@ -112,11 +130,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
if ( if (!canManageStudent(session.user?.role)) {
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); 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({ const student = await db.student.create({
data: parsed.data, data: parsed.data,
include: { include: {
@@ -153,11 +174,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
if ( if (!canManageStudent(session.user?.role)) {
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -185,6 +202,10 @@ export async function PUT(request: Request) {
const { id, ...updateData } = parsed.data; 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({ const student = await db.student.update({
where: { id }, where: { id },
data: updateData, data: updateData,
+13
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { canManageUser } from '@/lib/role-access';
const updateUserSchema = z.object({ const updateUserSchema = z.object({
firstName: z.string().min(1).optional(), firstName: z.string().min(1).optional(),
@@ -56,6 +57,10 @@ export async function GET(
return NextResponse.json({ error: 'User not found' }, { status: 404 }); 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 // School admins can only access users from their own school
if ( if (
session.user?.role === 'SCHOOL_ADMIN' && session.user?.role === 'SCHOOL_ADMIN' &&
@@ -108,6 +113,10 @@ export async function PUT(
return NextResponse.json({ error: 'User not found' }, { status: 404 }); 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 // School admins can only edit users from their own school
if ( if (
session.user?.role === 'SCHOOL_ADMIN' && session.user?.role === 'SCHOOL_ADMIN' &&
@@ -210,6 +219,10 @@ export async function DELETE(
return NextResponse.json({ error: 'User not found' }, { status: 404 }); 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 // School admins can only delete users from their own school
if ( if (
session.user?.role === 'SCHOOL_ADMIN' && session.user?.role === 'SCHOOL_ADMIN' &&
+6 -11
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getSession, hashPassword } from '@/lib/auth'; import { getSession, hashPassword } from '@/lib/auth';
import { isAdministrator } from '@/lib/role-access';
const createUserSchema = z.object({ const createUserSchema = z.object({
schoolId: z.string().nullable().optional(), schoolId: z.string().nullable().optional(),
@@ -31,8 +32,7 @@ export async function GET(request: Request) {
if ( if (
session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN'
session.user?.role !== 'TEACHER'
) { ) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -113,8 +113,7 @@ export async function POST(request: Request) {
if (body.action === 'bulkCreateStudents') { if (body.action === 'bulkCreateStudents') {
if ( if (
session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN'
session.user?.role !== 'TEACHER'
) { ) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -212,8 +211,7 @@ export async function POST(request: Request) {
// ── Single user creation ── // ── Single user creation ──
if ( if (
session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN'
session.user?.role !== 'TEACHER'
) { ) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
@@ -228,7 +226,7 @@ export async function POST(request: Request) {
const { password, studentId, ...userData } = parsed.data; 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 ( if (
session.user?.role === 'SCHOOL_ADMIN' && session.user?.role === 'SCHOOL_ADMIN' &&
session.user?.schoolId && session.user?.schoolId &&
@@ -242,10 +240,7 @@ export async function POST(request: Request) {
} }
// School admins cannot create super admin accounts // School admins cannot create super admin accounts
if ( if (session.user?.role === 'SCHOOL_ADMIN' && userData.role === 'SUPER_ADMIN') {
session.user?.role === 'SCHOOL_ADMIN' &&
userData.role === 'SUPER_ADMIN'
) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot create super admin accounts' }, { error: 'Cannot create super admin accounts' },
{ status: 403 } { status: 403 }
+75
View File
@@ -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<string[]> {
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<boolean> {
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<boolean> {
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;
}
+35
View File
@@ -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();
});
});
+56
View File
@@ -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;
}
+20
View File
@@ -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();
});
});
+35
View File
@@ -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;
}
+29 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import type { ViewName } from './store'; 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 = [ const mixedItems = [
{ key: 'dashboard' as const }, { key: 'dashboard' as const },
@@ -35,4 +42,25 @@ describe('role access', () => {
expect(canAccessView('SUPER_ADMIN', view)).toBeTrue(); 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();
});
}); });
+27 -15
View File
@@ -2,29 +2,26 @@ import type { ViewName } from '@/lib/store';
export type AppRole = 'PARENT' | 'STUDENT' | 'TEACHER' | 'VICE_PRINCIPAL' | 'SCHOOL_ADMIN' | 'SUPER_ADMIN' | 'DPO'; 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[] = [ const parentViews: ViewName[] = [
'dashboard', 'parent-portal', 'parents', 'grading', 'attendance', 'calendar', 'dashboard', 'parent-portal', 'calendar', 'communication', 'illness',
'communication', 'illness', 'report-cards', 'school-events', 'school-newsletter', 'notification-center', 'announcements',
'notification-center', 'announcements', 'settings',
]; ];
const studentViews: ViewName[] = [ const studentViews: ViewName[] = [
'dashboard', 'student-portal', 'notebooks', 'flower', 'grading', 'grade-analytics', 'dashboard', 'student-portal', 'calendar', 'communication', 'illness',
'homework', 'attendance', 'portfolio', 'calendar', 'competitions', 'subjects', 'notification-center',
'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',
]; ];
const teacherViews: ViewName[] = [ const teacherViews: ViewName[] = [
'dashboard', 'classes', 'competencies', 'progress', 'flower', 'assessments', 'grading', 'dashboard', 'classes', 'competencies', 'progress', 'flower', 'assessments', 'grading',
'reports', 'settings', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans', 'reports', 'student-detail', 'matrix', 'attendance', 'calendar', 'lesson-plans',
'parents', 'behavior', 'coverage', 'rubrics', 'comments', 'notebooks', 'drawing', 'homework', 'behavior', 'coverage', 'rubrics', 'comments', 'homework', 'timetable', 'resources',
'portfolio', 'timetable', 'resources', 'competitions', 'subjects', 'illness', 'communication', 'illness', 'communication', 'notification-center', 'tablet-grading', 'exam-calendar',
'counseling', 'ai-tests', 'notification-center', 'announcements', 'tablet-grading', 'seating-chart', 'report-cards',
'exam-calendar', 'peer-assessment', 'seating-chart', 'school-library', 'report-cards',
'student-wellness', 'student-career', 'school-events', 'school-newsletter',
]; ];
const vicePrincipalViews: ViewName[] = [ 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)); 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 { export function canAccessView(role: string | undefined, view: ViewName): boolean {
if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') return true; if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') return true;
const allowed = role === 'PARENT' ? parentViews const allowed = role === 'PARENT' ? parentViews