Add 4 files, update 15 files
This commit is contained in:
1 parent
a97794acf8
commit
2db87a1227
19 files changed
+401
-20
No files matched your search
+2
-1
@@ -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");
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 events = await db.calendarEvent.findMany({
|
const eventWhere: Record<string, unknown> = {
|
||||||
where: {
|
|
||||||
schoolId,
|
schoolId,
|
||||||
date: { gte: startOfMonth, lte: endOfMonth },
|
date: { gte: startOfMonth, lte: endOfMonth },
|
||||||
},
|
};
|
||||||
|
if (session.user.role === 'TEACHER') {
|
||||||
|
const classIds = await getTeacherClassIds(session.user.id);
|
||||||
|
eventWhere.OR = [{ teacherId: session.user.id }, { classGroupId: { in: classIds } }];
|
||||||
|
} else if (session.user.role === 'STUDENT') {
|
||||||
|
const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } });
|
||||||
|
const enrollments = student ? await db.enrollment.findMany({ where: { studentId: student.id, endDate: null }, select: { classGroupId: true } }) : [];
|
||||||
|
eventWhere.classGroupId = { in: enrollments.map((enrollment) => enrollment.classGroupId) };
|
||||||
|
} else if (session.user.role === 'PARENT') {
|
||||||
|
const links = await db.parentStudentLink.findMany({ where: { parentId: session.user.id }, select: { studentId: true } });
|
||||||
|
const enrollments = await db.enrollment.findMany({ where: { studentId: { in: links.map((link) => link.studentId) }, endDate: null }, select: { classGroupId: true } });
|
||||||
|
eventWhere.classGroupId = { in: enrollments.map((enrollment) => enrollment.classGroupId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await db.calendarEvent.findMany({
|
||||||
|
where: eventWhere,
|
||||||
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 } },
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -138,9 +138,15 @@ 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
|
||||||
const student = await db.student.findFirst({
|
// must explicitly name one linked child.
|
||||||
where: {
|
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: [
|
OR: [
|
||||||
{ userId },
|
{ userId },
|
||||||
{
|
{
|
||||||
@@ -150,7 +156,9 @@ export async function POST(request: Request) {
|
|||||||
schoolId: session.user?.schoolId,
|
schoolId: session.user?.schoolId,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
|
const student = await db.student.findFirst({
|
||||||
|
where: studentWhere,
|
||||||
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -772,6 +792,11 @@ function ChatArea({
|
|||||||
{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>
|
||||||
@@ -800,6 +825,11 @@ function ChatArea({
|
|||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user