Add 8 files, update 14 files
This commit is contained in:
1 parent
669e029f45
commit
c84b080fb8
22 files changed
+732
-101
No files matched your search
@@ -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");
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +23,15 @@ 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);
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +32,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
|
// 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);
|
||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
if (!canCreateDirectRoom(role)) {
|
||||||
const { reason } = body;
|
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 } },
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
@@ -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({
|
const updated = await db.illnessReport.update({
|
||||||
where: { id: reportId },
|
where: { id: reportId },
|
||||||
data: {
|
data: {
|
||||||
parentApprovalStatus: newStatus,
|
adminApprovalStatus: adminStatus,
|
||||||
parentApprovedBy: userId,
|
adminApprovedBy: userId,
|
||||||
parentApprovedAt: new Date(),
|
adminApprovedAt: new Date(),
|
||||||
isVisibleToTeacher: newStatus === 'approved',
|
isVisibleToAdmin: true,
|
||||||
isVisibleToAdmin: newStatus === 'approved',
|
status: adminStatus,
|
||||||
status: newStatus === 'approved' ? 'approved' : 'rejected',
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
student: { select: { id: true, firstName: true, lastName: true } },
|
student: { include: { enrollments: { where: { endDate: null }, include: { classGroup: true }, take: 1 } } },
|
||||||
reporter: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
approver: { select: { id: true, firstName: true, lastName: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
console.error('IllnessReport approve error:', error);
|
console.error('IllnessReport approve error:', error);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user