From 71b9ca0eb46d526d5c7c29ceb53e4504cd8644bc Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Sun, 2 Aug 2026 15:47:01 +0200 Subject: [PATCH] Update 31 files --- plans/role-communication-leave-workflow.md | 85 +++++++++++++++++++ src/app/api/calendar-events/[id]/route.ts | 17 ++++ src/app/api/counseling/route.ts | 11 ++- src/app/api/drawings/[id]/route.ts | 19 +++++ src/app/api/drawings/route.ts | 11 +++ src/app/api/emergency-contacts/route.ts | 11 +++ src/app/api/health-records/[id]/route.ts | 11 +++ src/app/api/health-records/route.ts | 11 +++ .../[id]/submissions/[submissionId]/route.ts | 13 ++- src/app/api/learning-goals/route.ts | 15 ++++ src/app/api/library/checkouts/route.ts | 16 ++++ src/app/api/notebooks/route.ts | 7 ++ .../[id]/submit/route.ts | 6 ++ src/app/api/peer-assessment-sessions/route.ts | 15 ++++ src/app/api/portfolio/route.ts | 20 +++++ src/app/api/report-cards/route.ts | 16 ++-- src/app/api/reports/route.ts | 22 +++++ src/app/api/resources/[id]/route.ts | 16 ++++ src/app/api/resources/route.ts | 16 ++++ .../api/school-events/[id]/register/route.ts | 25 ++++++ src/app/api/school-events/route.ts | 17 +++- src/app/api/student-achievements/route.ts | 20 +++++ src/app/api/student-answers/route.ts | 13 +++ src/app/api/student-badges/[id]/route.ts | 5 ++ src/app/api/student-badges/route.ts | 12 +++ src/app/api/student-goals/route.ts | 48 ++--------- src/app/api/student-study-planner/route.ts | 23 ++--- src/app/api/student-transport/[id]/route.ts | 11 +++ src/app/api/student-transport/route.ts | 11 +++ src/app/api/teacher-notes/route.ts | 7 ++ src/app/api/wellness/route.ts | 23 ++++- 31 files changed, 480 insertions(+), 73 deletions(-) diff --git a/plans/role-communication-leave-workflow.md b/plans/role-communication-leave-workflow.md index fe604e3..687cdf7 100644 --- a/plans/role-communication-leave-workflow.md +++ b/plans/role-communication-leave-workflow.md @@ -217,3 +217,88 @@ Status: implemented - Teachers cannot alter status, or edit/delete a leave once it is approved. - Vice principals cannot delete student leave records; reporters may only withdraw requests before either approval stage is processed. + +## Phase 9: Academic And Behavioral Data Isolation + +Status: implemented + +- Learning progress reads and writes enforce linked student, class, and teacher + scope rather than accepting arbitrary identifiers. +- Assessment results validate assessment class access and every target student. +- Homework submissions return only the logged-in student or linked parent + records, and student submissions resolve from the authenticated user ID. +- Behavior incidents are limited to a teacher's assigned classes for reads and + writes. + +## Phase 10: Sensitive Student Resource Isolation + +Status: implemented + +- Health records are administrator-only and additionally validate the target + student scope and school on every operation. +- Portfolio entries resolve to the current student or linked parent children + when no explicit authorized student is supplied. +- Notebook and drawing creation validates class ownership, school ownership, + and prevents students from publishing class-visible resources. + +## Phase 11: Personal Planning And Calendar Integrity + +Status: implemented + +- Calendar event updates and deletions require event ownership or administrator + authority, plus matching school and class access. +- Student goals and study plans now use the shared student access predicate for + all student, parent, teacher, and administrator callers. +- Badge awards and XP changes require an authorized teacher/administrator and + an accessible student; character updates cannot cross school boundaries. + +## Phase 12: Wellbeing And Private Record Isolation + +Status: implemented + +- Wellness API identity and role now come exclusively from the active session; + caller-controlled role query parameters can no longer grant access. +- Students may submit only their own daily wellness check-ins; teachers may + view only assigned classes. +- Teacher notes verify target student scope before reads or writes. +- Emergency contacts are restricted to administrators and require school plus + authorized target-student scope. + +## Phase 13: Operational Resource Isolation + +Status: implemented + +- Student transport records are administrator-only and verify target student + scope for read, create, update, and deletion. +- Library checkouts validate caller school, target student access, and book + school ownership before mutation. +- Student resource discovery is limited to public resources targeted at their + enrolled classes; resource creation verifies school and class ownership. +- School event creation requires a teacher/administrator and class scope; + student lists are restricted to all-school or their enrolled-class events. + +## Phase 14: Assessment And Report Isolation + +Status: implemented + +- Only students may submit lesson answers or peer assessments, and both flows + verify the authenticated student and assigned class. +- Teachers can only create/view peer assessment sessions in their own classes; + vice principals cannot create them. +- Learning goals and report cards enforce the shared student/class access + policy for every caller. +- Student and parent report lists are restricted to published reports for self + or linked children; teacher report lists are limited to assigned classes. + +## Phase 15: Single-Item Resource Integrity + +Status: implemented + +- Homework grading/deletion validates the homework class and target student + before a teacher or administrator can mutate a submission. +- Student badge queries, awards, and deletions validate school plus target + student scope. +- Single drawing/resource operations now enforce school and class visibility; + student-owned work cannot be republished class-wide. +- School-event registration, cancellation, and status changes prohibit user ID + spoofing and validate the caller's event school/class access. diff --git a/src/app/api/calendar-events/[id]/route.ts b/src/app/api/calendar-events/[id]/route.ts index 4696ce4..89c47df 100755 --- a/src/app/api/calendar-events/[id]/route.ts +++ b/src/app/api/calendar-events/[id]/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; import { addDays, addWeeks, addMonths, addYears, getDay } from 'date-fns'; interface RecurrencePattern { @@ -124,6 +126,15 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (!existing) { return NextResponse.json({ error: 'Event not found' }, { status: 404 }); } + if (!session.user || (!isAdministrator(session.user.role) && existing.teacherId !== session.user.id)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (classGroupId && !(await canAccessClass(session.user, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // If editing a single instance of a recurring event if (editMode === 'instance' && existing.parentEventId) { @@ -237,6 +248,12 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i if (!existing) { return NextResponse.json({ error: 'Event not found' }, { status: 404 }); } + if (!session.user || (!isAdministrator(session.user.role) && existing.teacherId !== session.user.id)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // If deleting a single instance, just delete that one if (deleteMode === 'instance') { diff --git a/src/app/api/counseling/route.ts b/src/app/api/counseling/route.ts index ce299fd..f374228 100755 --- a/src/app/api/counseling/route.ts +++ b/src/app/api/counseling/route.ts @@ -65,16 +65,23 @@ export async function POST(request: Request) { const body = await request.json(); const { requestType, description, addToCalendar, isPrivate } = body; const schoolId = body.schoolId || session.user?.schoolId; + const role = session.user?.role; if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + if (role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } - // Only students can request counseling - const role = session.user?.role; + // Only students can request counseling. if (role !== 'STUDENT' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.userId, schoolId, deletedAt: null }, select: { id: true } }); + if (!student) return NextResponse.json({ error: 'Student profile not found' }, { status: 403 }); + } // Find a counselor teacher for this school const counselor = await db.user.findFirst({ diff --git a/src/app/api/drawings/[id]/route.ts b/src/app/api/drawings/[id]/route.ts index f04e790..2394d26 100755 --- a/src/app/api/drawings/[id]/route.ts +++ b/src/app/api/drawings/[id]/route.ts @@ -2,6 +2,7 @@ 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'; const updateDrawingSchema = z.object({ title: z.string().min(1).optional(), @@ -31,11 +32,17 @@ export async function GET( if (!drawing) { return NextResponse.json({ error: 'Drawing not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && drawing.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Only owner or public drawings if (drawing.ownerId !== session.userId && !drawing.isPublic) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (drawing.isPublic && drawing.classGroupId && (!session.user || !(await canAccessClass(session.user, drawing.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } return NextResponse.json(drawing); } catch (error) { @@ -67,6 +74,9 @@ export async function PUT( if (existing.ownerId !== session.userId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (existing.schoolId !== session.user?.schoolId && session.user?.role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const body = await request.json(); const parsed = updateDrawingSchema.safeParse(body); @@ -76,6 +86,12 @@ export async function PUT( { status: 400 } ); } + if (parsed.data.classGroupId && (!session.user || !(await canAccessClass(session.user, parsed.data.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role === 'STUDENT' && parsed.data.isPublic) { + return NextResponse.json({ error: 'Students cannot publish drawings to a class' }, { status: 403 }); + } const drawing = await db.drawing.update({ where: { id }, @@ -112,6 +128,9 @@ export async function DELETE( if (existing.ownerId !== session.userId && session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (existing.schoolId !== session.user?.schoolId && session.user?.role !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } await db.drawing.delete({ where: { id } }); diff --git a/src/app/api/drawings/route.ts b/src/app/api/drawings/route.ts index bf25272..51a375a 100755 --- a/src/app/api/drawings/route.ts +++ b/src/app/api/drawings/route.ts @@ -2,6 +2,7 @@ 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'; const createDrawingSchema = z.object({ title: z.string().min(1).default('Untitled Drawing'), @@ -71,6 +72,16 @@ export async function POST(request: Request) { ); } + if (parsed.data.schoolId && session.user?.role !== 'SUPER_ADMIN' && parsed.data.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (parsed.data.classGroupId && (!session.user || !(await canAccessClass(session.user, parsed.data.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user?.role === 'STUDENT' && parsed.data.isPublic) { + return NextResponse.json({ error: 'Students cannot publish drawings to a class' }, { status: 403 }); + } + const drawing = await db.drawing.create({ data: { schoolId: parsed.data.schoolId ?? session.user?.schoolId ?? 'default', diff --git a/src/app/api/emergency-contacts/route.ts b/src/app/api/emergency-contacts/route.ts index 0f1dbc3..4dd659b 100755 --- a/src/app/api/emergency-contacts/route.ts +++ b/src/app/api/emergency-contacts/route.ts @@ -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) { try { @@ -20,12 +22,18 @@ export async function GET(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Emergency contacts are restricted to administrators' }, { status: 403 }); + } const where: Record = { schoolId, deletedAt: null, }; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; const contacts = await db.emergencyContact.findMany({ @@ -78,6 +86,9 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const contact = await db.emergencyContact.create({ data: { diff --git a/src/app/api/health-records/[id]/route.ts b/src/app/api/health-records/[id]/route.ts index 8fb8df2..f736d37 100755 --- a/src/app/api/health-records/[id]/route.ts +++ b/src/app/api/health-records/[id]/route.ts @@ -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, @@ -29,6 +31,9 @@ export async function GET( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== record.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, record.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } return NextResponse.json(record); } catch (error) { @@ -59,6 +64,9 @@ export async function PUT( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const body = await request.json(); const { @@ -126,6 +134,9 @@ export async function DELETE( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Soft delete await db.healthRecord.update({ diff --git a/src/app/api/health-records/route.ts b/src/app/api/health-records/route.ts index 0e9cc82..d815e69 100755 --- a/src/app/api/health-records/route.ts +++ b/src/app/api/health-records/route.ts @@ -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) { try { @@ -20,12 +22,18 @@ export async function GET(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Health records are restricted to administrators' }, { status: 403 }); + } const where: Record = { schoolId, deletedAt: null, }; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; const records = await db.healthRecord.findMany({ @@ -80,6 +88,9 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const record = await db.healthRecord.create({ data: { diff --git a/src/app/api/homework/[id]/submissions/[submissionId]/route.ts b/src/app/api/homework/[id]/submissions/[submissionId]/route.ts index ffe7d67..e23cb2b 100755 --- a/src/app/api/homework/[id]/submissions/[submissionId]/route.ts +++ b/src/app/api/homework/[id]/submissions/[submissionId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass, canAccessStudent } from '@/lib/access-policy'; const gradeSchema = z.object({ score: z.number().min(0).optional().nullable(), @@ -26,7 +27,7 @@ export async function PUT( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - const { submissionId } = await params; + const { id: homeworkId, submissionId } = await params; const body = await request.json(); const parsed = gradeSchema.safeParse(body); @@ -43,6 +44,10 @@ export async function PUT( if (!existing) { return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); } + const homework = await db.homework.findUnique({ where: { id: homeworkId }, select: { classGroupId: true } }); + if (!homework || !session.user || !(await canAccessClass(session.user, homework.classGroupId)) || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const updateData: Record = {}; if (parsed.data.score !== undefined) { @@ -82,13 +87,17 @@ export async function DELETE( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - const { submissionId } = await params; + const { id: homeworkId, submissionId } = await params; const existing = await db.homeworkSubmission.findUnique({ where: { id: submissionId, deletedAt: null }, }); if (!existing) { return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); } + const homework = await db.homework.findUnique({ where: { id: homeworkId }, select: { classGroupId: true } }); + if (!homework || !session.user || !(await canAccessClass(session.user, homework.classGroupId)) || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } await db.homeworkSubmission.update({ where: { id: submissionId }, diff --git a/src/app/api/learning-goals/route.ts b/src/app/api/learning-goals/route.ts index 05f2c29..741dab9 100755 --- a/src/app/api/learning-goals/route.ts +++ b/src/app/api/learning-goals/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; export async function GET(request: Request) { try { @@ -28,7 +29,18 @@ export async function GET(request: Request) { deletedAt: null, }; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; + if (!studentId && session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } }); + if (!student) return NextResponse.json([]); + where.studentId = student.id; + } else if (!studentId && session.user?.role === 'PARENT') { + const links = await db.parentStudentLink.findMany({ where: { parentId: session.user.id }, select: { studentId: true } }); + where.studentId = { in: links.map((link) => link.studentId) }; + } if (competencyId) where.competencyId = competencyId; if (status) where.status = status; @@ -90,6 +102,9 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const goal = await db.learningGoal.create({ data: { diff --git a/src/app/api/library/checkouts/route.ts b/src/app/api/library/checkouts/route.ts index af6a709..88d382b 100755 --- a/src/app/api/library/checkouts/route.ts +++ b/src/app/api/library/checkouts/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; // ── GET: List checkouts ────────────────────────────────────────────── export async function GET(request: Request) { @@ -22,6 +23,12 @@ export async function GET(request: Request) { if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + if (session.user?.role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const where: Record = { schoolId }; @@ -129,12 +136,21 @@ export async function POST(request: Request) { { status: 400 } ); } + if (session.user?.role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Check book availability const book = await db.libraryBook.findUnique({ where: { id: bookId } }); if (!book) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + if (book.schoolId !== schoolId) { + return NextResponse.json({ error: 'Book not found in this school' }, { status: 404 }); + } if (book.availableCopies <= 0) { return NextResponse.json({ error: 'No copies available' }, { status: 400 }); diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts index 31aad1c..025750e 100755 --- a/src/app/api/notebooks/route.ts +++ b/src/app/api/notebooks/route.ts @@ -2,6 +2,7 @@ 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'; // ── GET: List notebooks for the current user ── export async function GET(request: Request) { @@ -191,6 +192,12 @@ export async function POST(request: Request) { { status: 400 } ); } + if (classGroupId && !(await canAccessClass(session.user, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (session.user.role === 'STUDENT' && isPublic) { + return NextResponse.json({ error: 'Students cannot publish notebooks to a class' }, { status: 403 }); + } const ownerType = session.user.role === 'STUDENT' ? 'STUDENT' : 'TEACHER'; diff --git a/src/app/api/peer-assessment-sessions/[id]/submit/route.ts b/src/app/api/peer-assessment-sessions/[id]/submit/route.ts index e841a95..9779d19 100644 --- a/src/app/api/peer-assessment-sessions/[id]/submit/route.ts +++ b/src/app/api/peer-assessment-sessions/[id]/submit/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass } from '@/lib/access-policy'; export async function POST( request: Request, @@ -28,6 +29,9 @@ export async function POST( if (!assessmentSession) { return NextResponse.json({ error: 'Session not found' }, { status: 404 }); } + if (!session.user || !assessmentSession.classGroupId || !(await canAccessClass(session.user, assessmentSession.classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (assessmentSession.status === 'closed') { return NextResponse.json({ error: 'Session is closed' }, { status: 400 }); @@ -55,6 +59,8 @@ export async function POST( if (!student || student.id !== assessment.assessorId) { return NextResponse.json({ error: 'You can only submit your own assessments' }, { status: 403 }); } + } else { + return NextResponse.json({ error: 'Only students can submit peer assessments' }, { status: 403 }); } // Update the assessment diff --git a/src/app/api/peer-assessment-sessions/route.ts b/src/app/api/peer-assessment-sessions/route.ts index 6c16af9..f57e6b9 100644 --- a/src/app/api/peer-assessment-sessions/route.ts +++ b/src/app/api/peer-assessment-sessions/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass, getTeacherClassIds } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; export async function GET(request: Request) { try { @@ -23,6 +25,9 @@ export async function GET(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (classGroupId && (!session.user || !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const where: Record = { schoolId, @@ -42,6 +47,8 @@ export async function GET(request: Request) { if (student) { where.assignedPairs = { contains: student.id }; } + } else if (session.user?.role === 'TEACHER') { + where.classGroupId = { in: await getTeacherClassIds(session.user.id) }; } const sessions = await db.peerAssessmentSession.findMany({ @@ -126,6 +133,14 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (session.user?.role === 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Vice principals cannot create peer assessments' }, { status: 403 }); + } + if (session.user?.role === 'TEACHER') { + if ((teacherId && teacherId !== session.user.id) || (classGroupId && !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } const assessmentSession = await db.peerAssessmentSession.create({ data: { diff --git a/src/app/api/portfolio/route.ts b/src/app/api/portfolio/route.ts index dd6f6c6..16ce77f 100755 --- a/src/app/api/portfolio/route.ts +++ b/src/app/api/portfolio/route.ts @@ -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) { try { @@ -29,7 +31,22 @@ export async function GET(request: Request) { deletedAt: null, }; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; + if (!studentId && !isAdministrator(session.user?.role)) { + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } }); + if (!student) return NextResponse.json([]); + where.studentId = student.id; + } else if (session.user?.role === 'PARENT') { + const links = await db.parentStudentLink.findMany({ where: { parentId: session.user.id }, select: { studentId: true } }); + where.studentId = { in: links.map((link) => link.studentId) }; + } else { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } if (entryType) where.entryType = entryType; if (competencyId) where.competencyId = competencyId; if (isPublic !== null && isPublic !== undefined) where.isPublic = isPublic === 'true'; @@ -88,6 +105,9 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const entry = await db.portfolioEntry.create({ data: { diff --git a/src/app/api/report-cards/route.ts b/src/app/api/report-cards/route.ts index d0870e6..c25a6a4 100755 --- a/src/app/api/report-cards/route.ts +++ b/src/app/api/report-cards/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass, canAccessStudent, getTeacherClassIds } from '@/lib/access-policy'; const createReportCardSchema = z.object({ studentId: z.string().min(1), @@ -40,6 +41,12 @@ export async function GET(request: Request) { const templateId = searchParams.get('templateId'); const where: Record = {}; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (classGroupId && (!session.user || !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; if (classGroupId) where.classGroupId = classGroupId; if (schoolYearId) where.schoolYearId = schoolYearId; @@ -70,11 +77,7 @@ export async function GET(request: Request) { } else if (session.user?.role === 'TEACHER') { // Teachers see reports for their classes if (!classGroupId) { - const teacherClasses = await db.classGroupTeacher.findMany({ - where: { userId: session.userId }, - }); - const classIds = teacherClasses.map((c) => c.classGroupId); - where.classGroupId = { in: classIds }; + where.classGroupId = { in: await getTeacherClassIds(session.userId) }; } } @@ -129,6 +132,9 @@ export async function POST(request: Request) { } const { sections, ...reportData } = parsed.data; + if (!session.user || !(await canAccessStudent(session.user, reportData.studentId)) || !(await canAccessClass(session.user, reportData.classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // If attendance summary not provided, try to compute it let attendanceSummary = reportData.attendanceSummary; diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 4ad513d..d9e612f 100755 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass, canAccessStudent, getTeacherClassIds } from '@/lib/access-policy'; const createReportSchema = z.object({ studentId: z.string().min(1), @@ -34,8 +35,26 @@ export async function GET(request: Request) { const status = searchParams.get('status'); const where: Record = {}; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (classGroupId && (!session.user || !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; if (classGroupId) where.classGroupId = classGroupId; + if (!studentId && session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } }); + if (!student) return NextResponse.json([]); + where.studentId = student.id; + where.status = 'PUBLISHED'; + } else if (!studentId && session.user?.role === 'PARENT') { + const links = await db.parentStudentLink.findMany({ where: { parentId: session.user.id }, select: { studentId: true } }); + where.studentId = { in: links.map((link) => link.studentId) }; + where.status = 'PUBLISHED'; + } else if (!studentId && session.user?.role === 'TEACHER') { + where.classGroupId = { in: await getTeacherClassIds(session.user.id) }; + } if (schoolYearId) where.schoolYearId = schoolYearId; if (status) where.status = status; @@ -95,6 +114,9 @@ export async function POST(request: Request) { } const { sections, ...reportData } = parsed.data; + if (!session.user || !(await canAccessStudent(session.user, reportData.studentId)) || !(await canAccessClass(session.user, reportData.classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const report = await db.report.create({ data: { diff --git a/src/app/api/resources/[id]/route.ts b/src/app/api/resources/[id]/route.ts index 7ce56b6..daeb0d6 100755 --- a/src/app/api/resources/[id]/route.ts +++ b/src/app/api/resources/[id]/route.ts @@ -2,6 +2,7 @@ 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'; const resourceTypeEnum = z.enum([ 'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio', @@ -48,11 +49,17 @@ export async function GET( if (!resource) { return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && resource.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Students can only see public resources if (session.user?.role === 'STUDENT' && !resource.isPublic) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (resource.classGroupId && (!session.user || !(await canAccessClass(session.user, resource.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } return NextResponse.json(resource); } catch (error) { @@ -88,6 +95,9 @@ export async function PUT( if (!existing) { return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Only the author or admins can edit if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { @@ -111,6 +121,9 @@ export async function PUT( if (parsed.data.content !== undefined) updateData.content = parsed.data.content; if (parsed.data.subjectId !== undefined) updateData.subjectId = parsed.data.subjectId || null; if (parsed.data.classGroupId !== undefined) updateData.classGroupId = parsed.data.classGroupId || null; + if (parsed.data.classGroupId && (!session.user || !(await canAccessClass(session.user, parsed.data.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (parsed.data.gradeLevel !== undefined) updateData.gradeLevel = parsed.data.gradeLevel; if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags ? JSON.stringify(parsed.data.tags) : null; if (parsed.data.isPublic !== undefined) updateData.isPublic = parsed.data.isPublic; @@ -150,6 +163,9 @@ export async function DELETE( if (!existing) { return NextResponse.json({ error: 'Resource not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Only the author or admins can delete if (session.user?.role === 'TEACHER' && existing.authorId !== session.userId) { diff --git a/src/app/api/resources/route.ts b/src/app/api/resources/route.ts index 0ed78b4..6a01621 100755 --- a/src/app/api/resources/route.ts +++ b/src/app/api/resources/route.ts @@ -2,6 +2,7 @@ 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'; const resourceTypeEnum = z.enum([ 'document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio', @@ -50,17 +51,26 @@ export async function GET(request: Request) { if (!schoolId) { return NextResponse.json([]); } + if (session.user?.role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const where: Record = { schoolId, deletedAt: null }; // Students can only see public resources if (session.user?.role === 'STUDENT') { where.isPublic = true; + const student = await db.student.findFirst({ where: { userId: session.user.id, deletedAt: null }, select: { id: true } }); + const enrollments = student ? await db.enrollment.findMany({ where: { studentId: student.id, endDate: null }, select: { classGroupId: true } }) : []; + where.OR = [{ classGroupId: null }, { classGroupId: { in: enrollments.map((enrollment) => enrollment.classGroupId) } }]; } if (resourceType && resourceType !== 'all') where.resourceType = resourceType; if (subjectId && subjectId !== 'all') where.subjectId = subjectId; if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId; + if (classGroupId && classGroupId !== 'all' && !(await canAccessClass(session.user!, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (isPublic === 'true') where.isPublic = true; if (isPublic === 'false') where.isPublic = false; @@ -117,6 +127,9 @@ export async function POST(request: Request) { if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (session.user?.role === 'TEACHER' && session.user.schoolId !== school.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Verify subject if provided if (subjectId) { @@ -132,6 +145,9 @@ export async function POST(request: Request) { if (!classGroup || classGroup.schoolId !== schoolId) { return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 }); } + if (!session.user || !(await canAccessClass(session.user, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } } const resource = await db.resource.create({ diff --git a/src/app/api/school-events/[id]/register/route.ts b/src/app/api/school-events/[id]/register/route.ts index b99cd2c..65afd8b 100755 --- a/src/app/api/school-events/[id]/register/route.ts +++ b/src/app/api/school-events/[id]/register/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; export async function POST( request: Request, @@ -20,6 +22,12 @@ export async function POST( if (!event) { return NextResponse.json({ error: 'Event not found' }, { status: 404 }); } + if (session.user?.role !== 'SUPER_ADMIN' && event.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (event.classGroupId && (!session.user || !(await canAccessClass(session.user, event.classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (!event.requiresRegistration) { return NextResponse.json({ error: 'Event does not require registration' }, { status: 400 }); @@ -36,6 +44,9 @@ export async function POST( if (!userId) { return NextResponse.json({ error: 'userId is required' }, { status: 400 }); } + if (userId !== session.userId && !isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Check if already registered const existing = await db.eventRegistration.findUnique({ @@ -110,6 +121,13 @@ export async function PUT( if (!registration) { return NextResponse.json({ error: 'Registration not found' }, { status: 404 }); } + const event = await db.schoolEvent.findFirst({ where: { id, deletedAt: null } }); + if (!event || (session.user?.role !== 'SUPER_ADMIN' && event.schoolId !== session.user?.schoolId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (userId !== session.userId && !isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const updated = await db.eventRegistration.update({ where: { id: registration.id }, @@ -153,6 +171,13 @@ export async function DELETE( if (!registration) { return NextResponse.json({ error: 'Registration not found' }, { status: 404 }); } + const event = await db.schoolEvent.findFirst({ where: { id, deletedAt: null } }); + if (!event || (session.user?.role !== 'SUPER_ADMIN' && event.schoolId !== session.user?.schoolId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (userId !== session.userId && !isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Cancel registration (soft delete via status) const updated = await db.eventRegistration.update({ diff --git a/src/app/api/school-events/route.ts b/src/app/api/school-events/route.ts index 72d1f2c..47a0689 100755 --- a/src/app/api/school-events/route.ts +++ b/src/app/api/school-events/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessClass } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; export async function GET(request: Request) { try { @@ -27,11 +29,18 @@ export async function GET(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - + if (classGroupId && (!session.user || !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const where: Record = { schoolId, deletedAt: null, }; + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ where: { userId: session.user.id }, select: { id: true } }); + const classIds = student ? (await db.enrollment.findMany({ where: { studentId: student.id, endDate: null }, select: { classGroupId: true } })).map((enrollment) => enrollment.classGroupId) : []; + where.OR = [{ isAllSchool: true }, { classGroupId: { in: classIds } }]; + } if (eventType) where.eventType = eventType; if (classGroupId) where.classGroupId = classGroupId; @@ -131,6 +140,12 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) && session.user?.role !== 'TEACHER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (classGroupId && (!session.user || !(await canAccessClass(session.user, classGroupId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const event = await db.schoolEvent.create({ data: { diff --git a/src/app/api/student-achievements/route.ts b/src/app/api/student-achievements/route.ts index a6840ad..a26afce 100755 --- a/src/app/api/student-achievements/route.ts +++ b/src/app/api/student-achievements/route.ts @@ -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) { try { @@ -114,6 +116,9 @@ export async function GET(request: Request) { // Teacher/Admin view if (studentIdParam) { + if (!session.user || !(await canAccessStudent(session.user, studentIdParam))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const studentBadges = await db.studentBadge.findMany({ where: { schoolId, studentId: studentIdParam }, orderBy: { awardedAt: 'desc' }, @@ -164,6 +169,12 @@ export async function POST(request: Request) { if (!schoolId || !studentId || !badgeId) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + if (!isAdministrator(session.user?.role) && session.user?.role !== 'TEACHER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Check if already awarded const existing = await db.studentBadge.findUnique({ @@ -197,6 +208,12 @@ export async function POST(request: Request) { if (!schoolId || !studentId || xpAmount === undefined) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + if (!isAdministrator(session.user?.role) && session.user?.role !== 'TEACHER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const student = await db.student.findUnique({ where: { id: studentId }, select: { userId: true } }); if (!student?.userId) return NextResponse.json({ error: 'Student has no user account' }, { status: 400 }); @@ -209,6 +226,9 @@ export async function POST(request: Request) { if (!characterData) { return NextResponse.json({ error: 'Missing characterData' }, { status: 400 }); } + if (schoolId && session.user?.role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const character = await db.virtualCharacter.upsert({ where: { userId: session.userId }, diff --git a/src/app/api/student-answers/route.ts b/src/app/api/student-answers/route.ts index 81fdd5e..5f975df 100755 --- a/src/app/api/student-answers/route.ts +++ b/src/app/api/student-answers/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; import { withRateLimit } from '@/lib/rate-limit'; +import { getTeacherClassIds } from '@/lib/access-policy'; // ── GET: List student answers (for the current student) ── async function getAnswers(request: Request) { @@ -74,6 +75,10 @@ async function getAnswers(request: Request) { return NextResponse.json(answers); } + if (session.user.role === 'PARENT') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Teachers / Admins: can see answers for their school const schoolId = session.user.schoolId; if (!schoolId) { @@ -85,6 +90,11 @@ async function getAnswers(request: Request) { if (questionId) where.questionId = questionId; if (lessonId) { + if (session.user.role === 'TEACHER') { + const classIds = await getTeacherClassIds(session.user.id); + const lesson = await db.subjectLesson.findFirst({ where: { id: lessonId, topic: { schoolId } }, select: { id: true } }); + if (!lesson || classIds.length === 0) return NextResponse.json([]); + } const answers = await db.studentAnswer.findMany({ where: { question: { @@ -139,6 +149,9 @@ async function submitAnswer(request: Request) { if (!session?.user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } + if (session.user.role !== 'STUDENT') { + return NextResponse.json({ error: 'Only students can submit answers' }, { status: 403 }); + } const body = await request.json(); const parsed = submitAnswerSchema.safeParse(body); diff --git a/src/app/api/student-badges/[id]/route.ts b/src/app/api/student-badges/[id]/route.ts index e20252b..7c9d371 100755 --- a/src/app/api/student-badges/[id]/route.ts +++ b/src/app/api/student-badges/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; function isTeacherOrAdmin(role: string | undefined): boolean { return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; @@ -26,6 +27,8 @@ export async function GET( }); if (!studentBadge) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 }); + if (session.user?.role !== 'SUPER_ADMIN' && studentBadge.schoolId !== session.user?.schoolId) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + if (!session.user || !(await canAccessStudent(session.user, studentBadge.studentId))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json(studentBadge); } catch (error) { console.error('StudentBadge GET error:', error); @@ -46,6 +49,8 @@ export async function DELETE( const existing = await db.studentBadge.findUnique({ where: { id } }); if (!existing) return NextResponse.json({ error: 'Student badge not found' }, { status: 404 }); + if (session.user?.role !== 'SUPER_ADMIN' && existing.schoolId !== session.user?.schoolId) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + if (!session.user || !(await canAccessStudent(session.user, existing.studentId))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); await db.studentBadge.delete({ where: { id } }); diff --git a/src/app/api/student-badges/route.ts b/src/app/api/student-badges/route.ts index 49ef9a9..e4580e1 100755 --- a/src/app/api/student-badges/route.ts +++ b/src/app/api/student-badges/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; const awardBadgeSchema = z.object({ schoolId: z.string().min(1), @@ -35,6 +36,9 @@ export async function GET(request: Request) { if (!schoolId) return NextResponse.json([]); const where: Record = { schoolId }; + if (studentIdParam && (!session.user || !(await canAccessStudent(session.user, studentIdParam)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentIdParam) where.studentId = studentIdParam; if (badgeIdParam) where.badgeId = badgeIdParam; @@ -68,14 +72,22 @@ export async function POST(request: Request) { } const { schoolId, studentId, badgeId } = parsed.data; + if (session.user?.role !== 'SUPER_ADMIN' && schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Verify badge exists const badge = await db.badge.findUnique({ where: { id: badgeId, deletedAt: null } }); if (!badge) return NextResponse.json({ error: 'Badge not found' }, { status: 404 }); + if (badge.schoolId !== schoolId) return NextResponse.json({ error: 'Badge not found in this school' }, { status: 404 }); // Verify student exists const student = await db.student.findUnique({ where: { id: studentId, deletedAt: null } }); if (!student) return NextResponse.json({ error: 'Student not found' }, { status: 404 }); + if (student.schoolId !== schoolId) return NextResponse.json({ error: 'Student not found in this school' }, { status: 404 }); // Check if already awarded const existing = await db.studentBadge.findUnique({ diff --git a/src/app/api/student-goals/route.ts b/src/app/api/student-goals/route.ts index 202140e..55e1669 100755 --- a/src/app/api/student-goals/route.ts +++ b/src/app/api/student-goals/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; export async function GET(request: Request) { try { @@ -17,23 +18,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'studentId is required' }, { status: 400 }); } - // Verify the student belongs to the same school or is the student themselves - if (session.user?.role === 'STUDENT') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { userId: true, schoolId: true }, - }); - if (!student || student.userId !== session.user.id) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - } else if (session.user?.role !== 'SUPER_ADMIN') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { schoolId: true }, - }); - if (!student || student.schoolId !== session.user?.schoolId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const where: Record = { studentId }; @@ -68,32 +54,8 @@ export async function POST(request: Request) { ); } - // Verify the student belongs to the same school or is the student themselves - if (session.user?.role === 'STUDENT') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { userId: true, schoolId: true, firstName: true, lastName: true }, - }); - if (!student) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - // Check by userId (direct link) or by name matching (fallback for legacy data) - const isOwnStudent = student.userId === session.user.id || - (student.userId === null && - student.firstName === session.user.firstName && - student.lastName === session.user.lastName && - student.schoolId === session.user.schoolId); - if (!isOwnStudent) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - } else if (session.user?.role !== 'SUPER_ADMIN') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { schoolId: true }, - }); - if (!student || student.schoolId !== session.user?.schoolId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const goal = await db.studentGoal.create({ diff --git a/src/app/api/student-study-planner/route.ts b/src/app/api/student-study-planner/route.ts index b102b85..6f2f61b 100755 --- a/src/app/api/student-study-planner/route.ts +++ b/src/app/api/student-study-planner/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; export async function GET(request: Request) { try { @@ -34,15 +35,8 @@ export async function GET(request: Request) { if (!studentId) return NextResponse.json([]); - // Verify access - if (session.user?.role === 'STUDENT') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { userId: true }, - }); - if (!student || student.userId !== session.userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const studyPlans = await db.studyPlan.findMany({ @@ -125,15 +119,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - // Verify access - if (session.user?.role === 'STUDENT') { - const student = await db.student.findUnique({ - where: { id: studentId }, - select: { userId: true, schoolId: true }, - }); - if (!student || student.userId !== session.userId || student.schoolId !== schoolId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const plan = await db.studyPlan.create({ diff --git a/src/app/api/student-transport/[id]/route.ts b/src/app/api/student-transport/[id]/route.ts index b3b37e8..8034cc9 100755 --- a/src/app/api/student-transport/[id]/route.ts +++ b/src/app/api/student-transport/[id]/route.ts @@ -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, @@ -32,6 +34,9 @@ export async function GET( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== transport.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, transport.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } return NextResponse.json(transport); } catch (error) { @@ -62,6 +67,9 @@ export async function PUT( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const body = await request.json(); const { @@ -130,6 +138,9 @@ export async function DELETE( if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, existing.studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Soft delete await db.studentTransport.update({ diff --git a/src/app/api/student-transport/route.ts b/src/app/api/student-transport/route.ts index bc95bd3..3110ce7 100755 --- a/src/app/api/student-transport/route.ts +++ b/src/app/api/student-transport/route.ts @@ -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) { try { @@ -20,12 +22,18 @@ export async function GET(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role)) { + return NextResponse.json({ error: 'Student transport is restricted to administrators' }, { status: 403 }); + } const where: Record = { schoolId, deletedAt: null, }; + if (studentId && (!session.user || !(await canAccessStudent(session.user, studentId)))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (studentId) where.studentId = studentId; const transports = await db.studentTransport.findMany({ @@ -82,6 +90,9 @@ export async function POST(request: Request) { if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + if (!isAdministrator(session.user?.role) || !session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const transport = await db.studentTransport.create({ data: { diff --git a/src/app/api/teacher-notes/route.ts b/src/app/api/teacher-notes/route.ts index 525b184..4af30a8 100755 --- a/src/app/api/teacher-notes/route.ts +++ b/src/app/api/teacher-notes/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { canAccessStudent } from '@/lib/access-policy'; const noteCategoryEnum = z.enum([ 'GENERAL', @@ -49,6 +50,9 @@ export async function GET(request: Request) { if (!student) { return NextResponse.json({ error: 'Student not found' }, { status: 404 }); } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if ( session.user?.role === 'SCHOOL_ADMIN' && @@ -104,6 +108,9 @@ export async function POST(request: Request) { if (!student) { return NextResponse.json({ error: 'Student not found' }, { status: 404 }); } + if (!session.user || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if ( session.user?.role === 'SCHOOL_ADMIN' && diff --git a/src/app/api/wellness/route.ts b/src/app/api/wellness/route.ts index 4e3fcc2..6a31e9f 100755 --- a/src/app/api/wellness/route.ts +++ b/src/app/api/wellness/route.ts @@ -1,22 +1,30 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { canAccessClass, canAccessStudent } from '@/lib/access-policy'; +import { isAdministrator } from '@/lib/role-access'; // GET /api/wellness — Get wellness data for a student export async function GET(req: NextRequest) { try { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { searchParams } = new URL(req.url); const schoolId = searchParams.get('schoolId'); const studentId = searchParams.get('studentId'); - const role = searchParams.get('role') || 'STUDENT'; + const role = session.user.role; const classGroupId = searchParams.get('classGroupId'); const limit = parseInt(searchParams.get('limit') || '30'); if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); } + if (role !== 'SUPER_ADMIN' && schoolId !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } // Admin/VP: school-wide overview - if ((role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') && !studentId) { + if (isAdministrator(role) && !studentId) { const totalCheckins = await db.wellnessCheckin.count({ where: { schoolId }, }); @@ -55,6 +63,9 @@ export async function GET(req: NextRequest) { // Teacher: class overview if (role === 'TEACHER' && classGroupId && !studentId) { + if (!(await canAccessClass(session.user, classGroupId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const enrollments = await db.enrollment.findMany({ where: { classGroupId, endDate: null }, select: { studentId: true }, @@ -82,6 +93,9 @@ export async function GET(req: NextRequest) { // Student or specific student view if (studentId) { + if (!(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } const checkins = await db.wellnessCheckin.findMany({ where: { schoolId, studentId }, orderBy: { date: 'desc' }, @@ -112,12 +126,17 @@ export async function GET(req: NextRequest) { // POST /api/wellness — Submit daily check-in export async function POST(req: NextRequest) { try { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await req.json(); const { schoolId, studentId, mood, sleepHours, sleepQuality, stressLevel, activityType, activityMinutes, mealsCount, waterGlasses, gratitudeEntry, notes } = body; if (!schoolId || !studentId || mood === undefined) { return NextResponse.json({ error: 'schoolId, studentId, and mood are required' }, { status: 400 }); } + if (session.user.role !== 'STUDENT' || schoolId !== session.user.schoolId || !(await canAccessStudent(session.user, studentId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } if (mood < 1 || mood > 5) { return NextResponse.json({ error: 'Mood must be between 1 and 5' }, { status: 400 });