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

+37 -4
View File
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
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';
interface RecurrencePattern {
@@ -124,6 +126,19 @@ export async function POST(request: Request) {
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
let parsedPattern: RecurrencePattern | null = null;
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 });
}
if (!session.user || (!isAdministrator(session.user.role) && session.user.schoolId !== schoolId)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let year: number;
let monthIndex: number;
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 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({
where: {
schoolId,
date: { gte: startOfMonth, lte: endOfMonth },
},
where: eventWhere,
include: {
subject: { 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 { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { canAccessClass } from '@/lib/access-policy';
import { isAdministrator } from '@/lib/role-access';
const updateClassSchema = z.object({
name: z.string().min(1).optional(),
@@ -64,6 +66,10 @@ export async function GET(
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 = {
...classGroup,
studentCount: classGroup._count.enrollments,
@@ -94,11 +100,7 @@ export async function PUT(
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN' &&
session.user?.role !== 'TEACHER'
) {
if (!isAdministrator(session.user?.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
@@ -117,6 +119,10 @@ export async function PUT(
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 (parsed.data.responsibleTeacherId !== undefined) {
const teacherId = parsed.data.responsibleTeacherId;
@@ -226,6 +232,10 @@ export async function DELETE(
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.auditLog.create({
@@ -124,6 +124,9 @@ export async function PUT(
if (role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL' && role !== 'SUPER_ADMIN') {
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({
@@ -192,10 +195,14 @@ export async function DELETE(
const role = session.user?.role;
// Only admin can delete rooms
if (role !== 'SUPER_ADMIN' && role !== 'SCHOOL_ADMIN' && role !== 'VICE_PRINCIPAL') {
// Only school administrators can delete rooms. Vice principals may manage
// 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 });
}
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.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 });
}
// 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({
where: {
OR: [
{ userId },
{
userId: null,
firstName: session.user?.firstName,
lastName: session.user?.lastName,
schoolId: session.user?.schoolId,
},
],
},
where: studentWhere,
include: {
enrollments: {
where: { endDate: null },
@@ -183,7 +191,7 @@ export async function POST(request: Request) {
// Check if there's already an active/requested room
const existingRoom = await db.communicationRoom.findFirst({
where: {
studentId: userId,
studentId: role === 'PARENT' ? student.userId ?? userId : userId,
teacherId: teacher.id,
status: { in: ['requested', 'active'] },
},
@@ -196,7 +204,7 @@ export async function POST(request: Request) {
const room = await db.communicationRoom.create({
data: {
schoolId: student.schoolId,
studentId: userId,
studentId: role === 'PARENT' ? student.userId ?? userId : userId,
teacherId: teacher.id,
roomType: 'chat',
status: 'requested',
+10 -1
View File
@@ -63,7 +63,16 @@ export async function GET(request: Request) {
// 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 (parentApprovalStatus) where.parentApprovalStatus = parentApprovalStatus;
+26
View File
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { canAccessStudent } from '@/lib/access-policy';
import { isAdministrator } from '@/lib/role-access';
export async function GET(
request: Request,
@@ -52,6 +54,16 @@ export async function GET(
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
if (session.user?.role === 'PARENT' && link.parentId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
@@ -83,6 +95,13 @@ export async function PUT(
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
if (
session.user?.role !== 'TEACHER' &&
@@ -143,6 +162,13 @@ export async function DELETE(
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
if (
session.user?.role !== 'TEACHER' &&
+14
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { getTeacherClassIds } from '@/lib/access-policy';
const studentItemSchema = z.object({
firstName: z.string().min(1),
@@ -43,6 +44,19 @@ export async function POST(request: Request) {
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
if (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 { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET /api/substitutes/absences - List teacher absences
export async function GET(req: NextRequest) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const { searchParams } = new URL(req.url);
const schoolId = searchParams.get('schoolId');
const teacherId = searchParams.get('teacherId');
@@ -16,9 +19,13 @@ export async function GET(req: NextRequest) {
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 };
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 (status) where.status = status;
@@ -57,6 +64,8 @@ export async function GET(req: NextRequest) {
// POST /api/substitutes/absences - Create a teacher absence
export async function POST(req: NextRequest) {
try {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
const body = await req.json();
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 });
}
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({
data: {
schoolId,
@@ -74,6 +94,8 @@ export async function POST(req: NextRequest) {
reason: reason || null,
status: status || 'reported',
notes: notes || null,
approvalStatus: 'pending',
documentUrl: body.documentUrl || null,
},
include: {
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 { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { canManageUser } from '@/lib/role-access';
const changeRoleSchema = z.object({
userId: z.string().min(1),
@@ -48,6 +49,10 @@ export async function PUT(request: Request) {
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
if (
session.user?.role === 'SCHOOL_ADMIN' &&