Add 4 files, update 15 files

This commit is contained in:
[dyad] committed 2026-08-02 15:17:18 +02:00
1 parent a97794acf8
commit 2db87a1227
19 files changed
+414 -33

No files matched your search

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