Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform
Features: - 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings - 34+ API endpoints - 30+ Prisma models - 700+ i18n keys (German + English) - Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/) - Batch deletion, drag-and-drop seating, custom color palette - Demo accounts (admin, teacher, student, parent) - Dark mode, responsive design, print styles - CSV/PDF export, notification system, onboarding tour - Zero emojis — all replaced with Lucide icons - MIT License
This commit is contained in:
commit
0c1f739989
156 files changed
+51090
No files matched your search
@@ -0,0 +1,439 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// Helper: get ISO week key (YYYY-Www) for a date (Monday-based, ISO-8601)
|
||||
function isoWeekKey(date: Date): string {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
const day = d.getUTCDay() || 7; // make Sunday = 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day); // Thursday of this week
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startOfDate(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
// Build base where clause for LearningProgressEntry
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (classGroupId) {
|
||||
where.classGroupId = classGroupId;
|
||||
} else {
|
||||
// Scope by school via classGroup relation
|
||||
const classWhere: Record<string, unknown> = {};
|
||||
if (schoolId) classWhere.schoolId = schoolId;
|
||||
if (schoolYearId) classWhere.schoolYearId = schoolYearId;
|
||||
where.classGroup = classWhere;
|
||||
}
|
||||
|
||||
// Subject filter — via competency.category.competencyTemplate.subjectId
|
||||
if (subjectId) {
|
||||
where.competency = {
|
||||
category: {
|
||||
competencyTemplate: { subjectId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (startDate || endDate) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (startDate) dateFilter.gte = new Date(startDate);
|
||||
if (endDate) dateFilter.lte = new Date(endDate);
|
||||
where.date = dateFilter;
|
||||
}
|
||||
|
||||
// ── Fetch all matching entries with light relations ──
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where,
|
||||
orderBy: { date: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
masteryLevelValue: true,
|
||||
studentId: true,
|
||||
competencyId: true,
|
||||
classGroupId: true,
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
category: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
classGroup: { select: { id: true, name: true, schoolId: true, schoolYearId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Mastery Trend (weekly aggregation) ──
|
||||
const weekMap = new Map<string, { total: number; count: number; dateMs: number }>();
|
||||
for (const e of entries) {
|
||||
const wk = isoWeekKey(new Date(e.date));
|
||||
const cur = weekMap.get(wk) ?? { total: 0, count: 0, dateMs: 0 };
|
||||
cur.total += e.masteryLevelValue;
|
||||
cur.count += 1;
|
||||
const d = new Date(e.date);
|
||||
const ms = startOfDate(d).getTime();
|
||||
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
|
||||
weekMap.set(wk, cur);
|
||||
}
|
||||
const masteryTrend = Array.from(weekMap.entries())
|
||||
.map(([week, v]) => ({
|
||||
week,
|
||||
date: new Date(v.dateMs).toISOString(),
|
||||
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
||||
count: v.count,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
// ── Class Comparison (avg per class for the subject) ──
|
||||
const classAgg = new Map<string, { className: string; total: number; count: number; students: Set<string> }>();
|
||||
for (const e of entries) {
|
||||
const cid = e.classGroupId;
|
||||
const className = e.classGroup?.name ?? cid;
|
||||
const cur = classAgg.get(cid) ?? { className, total: 0, count: 0, students: new Set<string>() };
|
||||
cur.total += e.masteryLevelValue;
|
||||
cur.count += 1;
|
||||
cur.students.add(e.studentId);
|
||||
classAgg.set(cid, cur);
|
||||
}
|
||||
const classComparison = Array.from(classAgg.entries())
|
||||
.map(([classId, v]) => ({
|
||||
classId,
|
||||
className: v.className,
|
||||
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
||||
studentCount: v.students.size,
|
||||
}))
|
||||
.sort((a, b) => b.avgMastery - a.avgMastery);
|
||||
|
||||
// ── Mastery Distribution (levels 1-4) ──
|
||||
const distMap = new Map<number, number>([[1, 0], [2, 0], [3, 0], [4, 0]]);
|
||||
for (const e of entries) {
|
||||
const lvl = Math.min(4, Math.max(1, Math.floor(e.masteryLevelValue)));
|
||||
distMap.set(lvl, (distMap.get(lvl) ?? 0) + 1);
|
||||
}
|
||||
const totalEntries = entries.length;
|
||||
const masteryDistribution = Array.from(distMap.entries())
|
||||
.map(([level, count]) => ({
|
||||
level,
|
||||
count,
|
||||
percentage: totalEntries > 0 ? Math.round((count / totalEntries) * 1000) / 10 : 0,
|
||||
}))
|
||||
.sort((a, b) => a.level - b.level);
|
||||
|
||||
// ── Top/Bottom Competencies ──
|
||||
const compAgg = new Map<string, { code: string; title: string; total: number; count: number }>();
|
||||
for (const e of entries) {
|
||||
const cid = e.competencyId;
|
||||
const cur = compAgg.get(cid) ?? {
|
||||
code: e.competency?.code ?? cid,
|
||||
title: e.competency?.title ?? '',
|
||||
total: 0,
|
||||
count: 0,
|
||||
};
|
||||
cur.total += e.masteryLevelValue;
|
||||
cur.count += 1;
|
||||
compAgg.set(cid, cur);
|
||||
}
|
||||
const competencyArr = Array.from(compAgg.entries())
|
||||
.map(([competencyId, v]) => ({
|
||||
competencyId,
|
||||
code: v.code,
|
||||
title: v.title,
|
||||
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
||||
entryCount: v.count,
|
||||
}))
|
||||
.sort((a, b) => b.avgMastery - a.avgMastery);
|
||||
|
||||
const topCompetencies = competencyArr.slice(0, 5);
|
||||
const bottomCompetencies = competencyArr
|
||||
.slice()
|
||||
.sort((a, b) => a.avgMastery - b.avgMastery)
|
||||
.slice(0, 5);
|
||||
|
||||
// ── Activity Heatmap (last 90 days) ──
|
||||
const now = new Date();
|
||||
const ninetyAgo = new Date(now);
|
||||
ninetyAgo.setDate(ninetyAgo.getDate() - 89);
|
||||
const heatStart = startOfDate(ninetyAgo);
|
||||
const heatEnd = startOfDate(now);
|
||||
|
||||
const heatMap = new Map<string, number>();
|
||||
for (let d = new Date(heatStart); d.getTime() <= heatEnd.getTime(); d.setDate(d.getDate() + 1)) {
|
||||
heatMap.set(d.toISOString().slice(0, 10), 0);
|
||||
}
|
||||
for (const e of entries) {
|
||||
const ed = startOfDate(new Date(e.date));
|
||||
if (ed.getTime() < heatStart.getTime() || ed.getTime() > heatEnd.getTime()) continue;
|
||||
const key = ed.toISOString().slice(0, 10);
|
||||
heatMap.set(key, (heatMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const activityHeatmap = Array.from(heatMap.entries())
|
||||
.map(([date, count]) => ({ date, count }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// ── Grade Trend by Week (with class-average line) ──
|
||||
// For each ISO week: school avg mastery (entry-weighted), entry count,
|
||||
// unique students, class-average (simple mean of per-class averages).
|
||||
type TrendWeekAgg = {
|
||||
total: number;
|
||||
count: number;
|
||||
students: Set<string>;
|
||||
dateMs: number;
|
||||
classAgg: Map<string, { total: number; count: number }>;
|
||||
};
|
||||
const trendWeekMap = new Map<string, TrendWeekAgg>();
|
||||
for (const e of entries) {
|
||||
const wk = isoWeekKey(new Date(e.date));
|
||||
const cur = trendWeekMap.get(wk) ?? {
|
||||
total: 0,
|
||||
count: 0,
|
||||
students: new Set<string>(),
|
||||
dateMs: 0,
|
||||
classAgg: new Map<string, { total: number; count: number }>(),
|
||||
};
|
||||
cur.total += e.masteryLevelValue;
|
||||
cur.count += 1;
|
||||
cur.students.add(e.studentId);
|
||||
const ms = startOfDate(new Date(e.date)).getTime();
|
||||
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
|
||||
const cid = e.classGroupId;
|
||||
const ca = cur.classAgg.get(cid) ?? { total: 0, count: 0 };
|
||||
ca.total += e.masteryLevelValue;
|
||||
ca.count += 1;
|
||||
cur.classAgg.set(cid, ca);
|
||||
trendWeekMap.set(wk, cur);
|
||||
}
|
||||
const gradeTrend = Array.from(trendWeekMap.entries())
|
||||
.map(([week, v]) => {
|
||||
const classMeans: number[] = [];
|
||||
for (const ca of v.classAgg.values()) {
|
||||
if (ca.count > 0) classMeans.push(ca.total / ca.count);
|
||||
}
|
||||
const classAvg =
|
||||
classMeans.length > 0
|
||||
? Math.round((classMeans.reduce((s, m) => s + m, 0) / classMeans.length) * 100) / 100
|
||||
: 0;
|
||||
const weekNo = parseInt(week.split('-W')[1] ?? '0', 10);
|
||||
return {
|
||||
week,
|
||||
weekLabel: `KW${weekNo}`,
|
||||
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
||||
entryCount: v.count,
|
||||
uniqueStudents: v.students.size,
|
||||
classAvg,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.week.localeCompare(b.week));
|
||||
|
||||
// ── At-Risk Students (Risk Analysis) ──
|
||||
// Step 1: identify students in scope (via Enrollment → ClassGroup filter)
|
||||
const enrollmentWhere: Record<string, unknown> = { endDate: null };
|
||||
const enrClassWhere: Record<string, unknown> = {};
|
||||
if (schoolId) enrClassWhere.schoolId = schoolId;
|
||||
if (schoolYearId) enrClassWhere.schoolYearId = schoolYearId;
|
||||
if (classGroupId) enrClassWhere.id = classGroupId;
|
||||
if (Object.keys(enrClassWhere).length > 0) {
|
||||
enrollmentWhere.classGroup = enrClassWhere;
|
||||
}
|
||||
|
||||
let enrollments: Array<{
|
||||
studentId: string;
|
||||
classGroupId: string;
|
||||
student: { id: string; firstName: string; lastName: string; deletedAt: string | null } | null;
|
||||
classGroup: { id: string; name: string } | null;
|
||||
}> = [];
|
||||
try {
|
||||
enrollments = await db.enrollment.findMany({
|
||||
where: enrollmentWhere,
|
||||
select: {
|
||||
studentId: true,
|
||||
classGroupId: true,
|
||||
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
enrollments = [];
|
||||
}
|
||||
|
||||
// Build studentId -> { name, className } (latest enrollment wins)
|
||||
const studentMeta = new Map<string, { studentName: string; className: string }>();
|
||||
for (const enr of enrollments) {
|
||||
if (!enr.student || enr.student.deletedAt) continue;
|
||||
const name = `${enr.student.firstName ?? ''} ${enr.student.lastName ?? ''}`.trim();
|
||||
studentMeta.set(enr.studentId, {
|
||||
studentName: name || enr.studentId,
|
||||
className: enr.classGroup?.name ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: group entries by studentId (only entries in scope are already filtered)
|
||||
const studentEntries = new Map<string, Array<{ date: Date; masteryLevelValue: number }>>();
|
||||
for (const e of entries) {
|
||||
const arr = studentEntries.get(e.studentId) ?? [];
|
||||
arr.push({ date: new Date(e.date), masteryLevelValue: e.masteryLevelValue });
|
||||
studentEntries.set(e.studentId, arr);
|
||||
}
|
||||
|
||||
// Step 3: batch-fetch attendance records (wrapped in try/catch)
|
||||
const attendanceMap = new Map<string, { present: number; total: number }>();
|
||||
const studentIds = Array.from(studentMeta.keys());
|
||||
if (studentIds.length > 0) {
|
||||
try {
|
||||
const attendanceRecords = await db.attendanceRecord.findMany({
|
||||
where: { studentId: { in: studentIds } },
|
||||
select: { studentId: true, status: true },
|
||||
});
|
||||
for (const r of attendanceRecords) {
|
||||
const cur = attendanceMap.get(r.studentId) ?? { present: 0, total: 0 };
|
||||
cur.total += 1;
|
||||
if (r.status === 'PRESENT' || r.status === 'LATE') cur.present += 1;
|
||||
attendanceMap.set(r.studentId, cur);
|
||||
}
|
||||
} catch {
|
||||
// Attendance data may not exist — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: compute risk score & signals per student
|
||||
const nowMs = Date.now();
|
||||
const fourteenDaysMs = 14 * 86400000;
|
||||
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
type RiskSignal = 'low_mastery' | 'no_recent_progress' | 'low_attendance' | 'declining';
|
||||
type AtRiskEntry = {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
className: string;
|
||||
riskScore: number;
|
||||
riskLevel: RiskLevel;
|
||||
signals: RiskSignal[];
|
||||
latestMastery: number;
|
||||
latestEntryDate: string;
|
||||
};
|
||||
const atRiskList: AtRiskEntry[] = [];
|
||||
|
||||
for (const [studentId, meta] of studentMeta) {
|
||||
const eArr = studentEntries.get(studentId) ?? [];
|
||||
const signals: RiskSignal[] = [];
|
||||
let riskScore = 0;
|
||||
let avgMastery = 0;
|
||||
let latestMastery = 0;
|
||||
let latestEntryDate: string | null = null;
|
||||
|
||||
if (eArr.length > 0) {
|
||||
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
avgMastery = sorted.reduce((s, e) => s + e.masteryLevelValue, 0) / sorted.length;
|
||||
latestMastery = sorted[sorted.length - 1].masteryLevelValue;
|
||||
latestEntryDate = sorted[sorted.length - 1].date.toISOString().slice(0, 10);
|
||||
|
||||
if (avgMastery < 2.0) {
|
||||
signals.push('low_mastery');
|
||||
riskScore += 30;
|
||||
if (avgMastery < 1.5) {
|
||||
riskScore += 20;
|
||||
}
|
||||
}
|
||||
|
||||
const latestMs = sorted[sorted.length - 1].date.getTime();
|
||||
if (nowMs - latestMs > fourteenDaysMs) {
|
||||
signals.push('no_recent_progress');
|
||||
riskScore += 20;
|
||||
}
|
||||
|
||||
// Declining trend: compare last 4 entries to previous 4
|
||||
if (sorted.length >= 8) {
|
||||
const prev4 = sorted.slice(-8, -4);
|
||||
const last4 = sorted.slice(-4);
|
||||
const prevAvg = prev4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
|
||||
const lastAvg = last4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
|
||||
if (lastAvg < prevAvg) {
|
||||
signals.push('declining');
|
||||
riskScore += 15;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No progress entries at all → counts as no recent progress
|
||||
signals.push('no_recent_progress');
|
||||
riskScore += 20;
|
||||
}
|
||||
|
||||
// Attendance (data may be missing for some students)
|
||||
const att = attendanceMap.get(studentId);
|
||||
if (att && att.total > 0) {
|
||||
const rate = (att.present / att.total) * 100;
|
||||
if (rate < 80) {
|
||||
signals.push('low_attendance');
|
||||
riskScore += 15;
|
||||
}
|
||||
}
|
||||
|
||||
if (riskScore === 0) continue;
|
||||
|
||||
let riskLevel: RiskLevel;
|
||||
if (riskScore >= 65) riskLevel = 'critical';
|
||||
else if (riskScore >= 45) riskLevel = 'high';
|
||||
else if (riskScore >= 25) riskLevel = 'medium';
|
||||
else riskLevel = 'low';
|
||||
|
||||
atRiskList.push({
|
||||
studentId,
|
||||
studentName: meta.studentName,
|
||||
className: meta.className,
|
||||
riskScore: Math.min(100, riskScore),
|
||||
riskLevel,
|
||||
signals,
|
||||
latestMastery: Math.round(latestMastery * 100) / 100,
|
||||
latestEntryDate: latestEntryDate ?? new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
atRiskList.sort((a, b) => b.riskScore - a.riskScore);
|
||||
const atRiskStudents = atRiskList.slice(0, 10);
|
||||
|
||||
// ── Summary stats ──
|
||||
const totalStudents = new Set(entries.map((e) => e.studentId)).size;
|
||||
const overallAvgMastery =
|
||||
totalEntries > 0
|
||||
? Math.round(
|
||||
(entries.reduce((sum, e) => sum + e.masteryLevelValue, 0) / totalEntries) * 100
|
||||
) / 100
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
masteryTrend,
|
||||
classComparison,
|
||||
masteryDistribution,
|
||||
topCompetencies,
|
||||
bottomCompetencies,
|
||||
activityHeatmap,
|
||||
gradeTrend,
|
||||
atRiskStudents,
|
||||
totalEntries,
|
||||
totalStudents,
|
||||
overallAvgMastery,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Analytics GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const resultSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
score: z.number().nullable().optional(),
|
||||
masteryLevelValue: z.number().int().nullable().optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
|
||||
const bulkResultSchema = z.array(resultSchema);
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: assessmentId } = await params;
|
||||
|
||||
const results = await db.assessmentResult.findMany({
|
||||
where: { assessmentId },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
assessment: {
|
||||
select: { id: true, title: true, maxScore: true },
|
||||
},
|
||||
},
|
||||
orderBy: { student: { lastName: 'asc' } },
|
||||
});
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error('Assessment results GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id: assessmentId } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const isBulk = Array.isArray(body);
|
||||
|
||||
if (isBulk) {
|
||||
const parsed = bulkResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert each result (assessmentId + studentId is unique)
|
||||
const results: Array<{ id: string; studentId: string; score: number | null; masteryLevelValue: number | null; note: string | null; assessmentId: string }> = [];
|
||||
for (const item of parsed.data) {
|
||||
const result = await db.assessmentResult.upsert({
|
||||
where: {
|
||||
assessmentId_studentId: {
|
||||
assessmentId,
|
||||
studentId: item.studentId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score: item.score ?? null,
|
||||
masteryLevelValue: item.masteryLevelValue ?? null,
|
||||
note: item.note ?? null,
|
||||
},
|
||||
create: {
|
||||
assessmentId,
|
||||
studentId: item.studentId,
|
||||
score: item.score ?? null,
|
||||
masteryLevelValue: item.masteryLevelValue ?? null,
|
||||
note: item.note ?? null,
|
||||
},
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ created: results.length },
|
||||
{ status: 201 }
|
||||
);
|
||||
} else {
|
||||
const parsed = resultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.assessmentResult.upsert({
|
||||
where: {
|
||||
assessmentId_studentId: {
|
||||
assessmentId,
|
||||
studentId: parsed.data.studentId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
score: parsed.data.score ?? null,
|
||||
masteryLevelValue: parsed.data.masteryLevelValue ?? null,
|
||||
note: parsed.data.note ?? null,
|
||||
},
|
||||
create: {
|
||||
assessmentId,
|
||||
studentId: parsed.data.studentId,
|
||||
score: parsed.data.score ?? null,
|
||||
masteryLevelValue: parsed.data.masteryLevelValue ?? null,
|
||||
note: parsed.data.note ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Assessment results POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const competencyLinkSchema = z.object({
|
||||
competencyId: z.string().min(1),
|
||||
weight: z.number().default(1.0),
|
||||
});
|
||||
|
||||
const createAssessmentSchema = z.object({
|
||||
classGroupId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
teacherId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().transform((v) => new Date(v)),
|
||||
type: z.enum(['TEST', 'ORAL', 'PROJECT', 'HOMEWORK', 'OTHER']).default('TEST'),
|
||||
maxScore: z.number().nullable().optional(),
|
||||
weight: z.number().default(1.0),
|
||||
competencyLinks: z.array(competencyLinkSchema).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
|
||||
const assessments = await db.assessment.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
assessmentCompetencyLinks: {
|
||||
include: {
|
||||
competency: {
|
||||
select: { id: true, code: true, title: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { assessmentResults: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(assessments);
|
||||
} catch (error) {
|
||||
console.error('Assessments GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createAssessmentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { competencyLinks, ...assessmentData } = parsed.data;
|
||||
|
||||
const assessment = await db.assessment.create({
|
||||
data: {
|
||||
classGroupId: assessmentData.classGroupId,
|
||||
subjectId: assessmentData.subjectId,
|
||||
teacherId: assessmentData.teacherId,
|
||||
title: assessmentData.title,
|
||||
date: assessmentData.date,
|
||||
type: assessmentData.type,
|
||||
maxScore: assessmentData.maxScore ?? null,
|
||||
weight: assessmentData.weight,
|
||||
assessmentCompetencyLinks: competencyLinks
|
||||
? {
|
||||
create: competencyLinks.map((link) => ({
|
||||
competencyId: link.competencyId,
|
||||
weight: link.weight,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
assessmentCompetencyLinks: {
|
||||
include: {
|
||||
competency: { select: { id: true, code: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(assessment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Assessments POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const statusEnum = z.enum(['PRESENT', 'ABSENT', 'EXCUSED', 'LATE']);
|
||||
|
||||
const updateRecordSchema = z.object({
|
||||
status: statusEnum.optional(),
|
||||
arrivalTime: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateRecordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.attendanceRecord.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
session: {
|
||||
include: { classGroup: { select: { schoolId: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Record not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.session.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.session.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.arrivalTime !== undefined) data.arrivalTime = parsed.data.arrivalTime;
|
||||
if (parsed.data.comment !== undefined) data.comment = parsed.data.comment;
|
||||
|
||||
const record = await db.attendanceRecord.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(record);
|
||||
} catch (error) {
|
||||
console.error('AttendanceRecord PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.attendanceSession.findUnique({
|
||||
where: { id },
|
||||
include: { classGroup: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete records first, then session
|
||||
await db.attendanceRecord.deleteMany({
|
||||
where: { sessionId: id },
|
||||
});
|
||||
|
||||
await db.attendanceSession.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.classGroup.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'AttendanceSession',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
classGroupId: existing.classGroupId,
|
||||
date: existing.date.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('AttendanceSession DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const statusEnum = z.enum(['PRESENT', 'ABSENT', 'EXCUSED', 'LATE']);
|
||||
|
||||
const createSessionSchema = z.object({
|
||||
classGroupId: z.string().min(1),
|
||||
date: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
period: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateSessionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['OPEN', 'COMPLETED']).optional(),
|
||||
records: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
status: statusEnum.optional(),
|
||||
arrivalTime: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
if (!classGroupId) {
|
||||
return NextResponse.json({ error: 'classGroupId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify class belongs to user's school
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!classGroup) {
|
||||
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
classGroup.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { classGroupId };
|
||||
if (dateFrom || dateTo) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
where.date = dateFilter;
|
||||
}
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const sessions = await db.attendanceSession.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
records: {
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { student: { lastName: 'asc' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Attendance GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSessionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { classGroupId, date, subjectId, period } = parsed.data;
|
||||
|
||||
// Verify class exists
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!classGroup) {
|
||||
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
classGroup.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all enrolled students for the class
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId,
|
||||
endDate: null,
|
||||
},
|
||||
select: { studentId: true },
|
||||
});
|
||||
|
||||
// Create the session with records for all students
|
||||
const attendanceSession = await db.attendanceSession.create({
|
||||
data: {
|
||||
classGroupId,
|
||||
teacherId: session.userId,
|
||||
date: new Date(date),
|
||||
subjectId: subjectId || null,
|
||||
period: period || null,
|
||||
status: 'OPEN',
|
||||
records: {
|
||||
create: enrollments.map((e) => ({
|
||||
studentId: e.studentId,
|
||||
status: 'PRESENT',
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
records: {
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { student: { lastName: 'asc' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: classGroup.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'AttendanceSession',
|
||||
entityId: attendanceSession.id,
|
||||
metadata: JSON.stringify({
|
||||
classGroupId,
|
||||
date,
|
||||
studentCount: enrollments.length,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(attendanceSession, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Attendance POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateSessionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = parsed.data;
|
||||
|
||||
const existing = await db.attendanceSession.findUnique({
|
||||
where: { id },
|
||||
include: { classGroup: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update session status if provided
|
||||
if (parsed.data.status !== undefined) {
|
||||
await db.attendanceSession.update({
|
||||
where: { id },
|
||||
data: { status: parsed.data.status },
|
||||
});
|
||||
}
|
||||
|
||||
// Update records if provided
|
||||
if (parsed.data.records && parsed.data.records.length > 0) {
|
||||
for (const record of parsed.data.records) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (record.status !== undefined) updateData.status = record.status;
|
||||
if (record.arrivalTime !== undefined) updateData.arrivalTime = record.arrivalTime;
|
||||
if (record.comment !== undefined) updateData.comment = record.comment;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.attendanceRecord.update({
|
||||
where: { id: record.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated session
|
||||
const updated = await db.attendanceSession.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
records: {
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { student: { lastName: 'asc' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.classGroup.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'AttendanceSession',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
status: parsed.data.status,
|
||||
recordsUpdated: parsed.data.records?.length ?? 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Attendance PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const action = searchParams.get('action');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
// If not super admin, restrict to user's school
|
||||
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
|
||||
where.schoolId = session.user.schoolId;
|
||||
} else if (schoolId) {
|
||||
where.schoolId = schoolId;
|
||||
}
|
||||
|
||||
if (action) where.action = action;
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.timestamp = {
|
||||
...(startDate ? { gte: new Date(startDate) } : {}),
|
||||
...(endDate ? { lte: new Date(endDate) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await db.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 200,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entries);
|
||||
} catch (error) {
|
||||
console.error('AuditLog GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createSession,
|
||||
getSession,
|
||||
clearSession,
|
||||
} from '@/lib/auth';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
schoolId: z.string().optional(),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
// ── Login ──
|
||||
if (action === 'login') {
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
schoolId: user.schoolId,
|
||||
locale: user.locale,
|
||||
isDemo: user.isDemo,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Register ──
|
||||
if (action === 'register') {
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, schoolId, role } =
|
||||
parsed.data;
|
||||
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email already registered' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
schoolId: schoolId ?? null,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
await createSession(user.id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
schoolId: user.schoolId,
|
||||
locale: user.locale,
|
||||
isDemo: user.isDemo,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logout ──
|
||||
if (action === 'logout') {
|
||||
await clearSession();
|
||||
return NextResponse.json({ message: 'Logged out' });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || !session.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.json(session.user);
|
||||
} catch (error) {
|
||||
console.error('Auth me error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const valenceEnum = z.enum(['positive', 'negative', 'neutral']);
|
||||
|
||||
const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981')
|
||||
.optional(),
|
||||
valence: valenceEnum.optional(),
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const category = await db.behaviorCategory.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { incidents: true } } },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
category.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(category);
|
||||
} catch (error) {
|
||||
console.error('BehaviorCategory GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateCategorySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.behaviorCategory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check name uniqueness if name is being changed
|
||||
if (parsed.data.name && parsed.data.name !== existing.name) {
|
||||
const dupe = await db.behaviorCategory.findUnique({
|
||||
where: { schoolId_name: { schoolId: existing.schoolId, name: parsed.data.name } },
|
||||
});
|
||||
if (dupe) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A category with this name already exists in this school' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||
if (parsed.data.color !== undefined) data.color = parsed.data.color;
|
||||
if (parsed.data.valence !== undefined) data.valence = parsed.data.valence;
|
||||
if (parsed.data.icon !== undefined) data.icon = parsed.data.icon ?? null;
|
||||
|
||||
const updated = await db.behaviorCategory.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'BehaviorCategory',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('BehaviorCategory PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.behaviorCategory.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { incidents: true } } },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Block deletion if incidents exist
|
||||
if (existing._count.incidents > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot delete: category is referenced by existing incidents',
|
||||
incidentCount: existing._count.incidents,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.behaviorCategory.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'BehaviorCategory',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ name: existing.name, valence: existing.valence }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('BehaviorCategory DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const valenceEnum = z.enum(['positive', 'negative', 'neutral']);
|
||||
|
||||
const createCategorySchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981'),
|
||||
valence: valenceEnum,
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
|
||||
// SCHOOL_ADMIN: forced to own school. SUPER_ADMIN/TEACHER: respect query, fallback to own.
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const categories = await db.behaviorCategory.findMany({
|
||||
where: { schoolId },
|
||||
orderBy: [{ valence: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { incidents: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(categories);
|
||||
} catch (error) {
|
||||
console.error('BehaviorCategories GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createCategorySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, name } = parsed.data;
|
||||
|
||||
// Verify school exists; enforce SCHOOL_ADMIN scope
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
school.id !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check for duplicate name within school
|
||||
const existing = await db.behaviorCategory.findUnique({
|
||||
where: { schoolId_name: { schoolId, name } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A category with this name already exists in this school' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const category = await db.behaviorCategory.create({
|
||||
data: {
|
||||
schoolId,
|
||||
name,
|
||||
color: parsed.data.color,
|
||||
valence: parsed.data.valence,
|
||||
icon: parsed.data.icon ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'BehaviorCategory',
|
||||
entityId: category.id,
|
||||
metadata: JSON.stringify({ name, valence: parsed.data.valence, color: parsed.data.color }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(category, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('BehaviorCategories POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const severityEnum = z.enum(['minor', 'moderate', 'major']);
|
||||
|
||||
const updateIncidentSchema = z.object({
|
||||
studentId: z.string().min(1).optional(),
|
||||
classGroupId: z.string().min(1).optional().nullable(),
|
||||
categoryId: z.string().min(1).optional(),
|
||||
date: z.string().min(1).optional(),
|
||||
severity: severityEnum.optional(),
|
||||
description: z.string().min(1).max(2000).optional(),
|
||||
location: z.string().max(200).optional().nullable(),
|
||||
followUpAction: z.string().max(200).optional().nullable(),
|
||||
resolved: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const incident = await db.behaviorIncident.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
|
||||
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!incident) {
|
||||
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
incident.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(incident);
|
||||
} catch (error) {
|
||||
console.error('BehaviorIncident GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateIncidentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.behaviorIncident.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Validate references when they're being changed
|
||||
if (parsed.data.studentId && parsed.data.studentId !== existing.studentId) {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: parsed.data.studentId },
|
||||
select: { id: true, schoolId: true, deletedAt: true },
|
||||
});
|
||||
if (!student || student.schoolId !== existing.schoolId) {
|
||||
return NextResponse.json({ error: 'Student not found in this school' }, { status: 404 });
|
||||
}
|
||||
if (student.deletedAt) {
|
||||
return NextResponse.json({ error: 'Student is deleted' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
if (parsed.data.categoryId && parsed.data.categoryId !== existing.categoryId) {
|
||||
const category = await db.behaviorCategory.findUnique({
|
||||
where: { id: parsed.data.categoryId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!category || category.schoolId !== existing.schoolId) {
|
||||
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
if (parsed.data.classGroupId) {
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: parsed.data.classGroupId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!classGroup || classGroup.schoolId !== existing.schoolId) {
|
||||
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.studentId !== undefined) data.studentId = parsed.data.studentId;
|
||||
if (parsed.data.classGroupId !== undefined) data.classGroupId = parsed.data.classGroupId || null;
|
||||
if (parsed.data.categoryId !== undefined) data.categoryId = parsed.data.categoryId;
|
||||
if (parsed.data.date !== undefined) data.date = new Date(parsed.data.date);
|
||||
if (parsed.data.severity !== undefined) data.severity = parsed.data.severity;
|
||||
if (parsed.data.description !== undefined) data.description = parsed.data.description;
|
||||
if (parsed.data.location !== undefined) data.location = parsed.data.location ?? null;
|
||||
if (parsed.data.followUpAction !== undefined) data.followUpAction = parsed.data.followUpAction ?? null;
|
||||
|
||||
// Handle resolved transitions
|
||||
if (parsed.data.resolved !== undefined && parsed.data.resolved !== existing.resolved) {
|
||||
data.resolved = parsed.data.resolved;
|
||||
if (parsed.data.resolved) {
|
||||
data.resolvedAt = new Date();
|
||||
data.resolvedById = session.userId;
|
||||
} else {
|
||||
data.resolvedAt = null;
|
||||
data.resolvedById = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.behaviorIncident.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
|
||||
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'BehaviorIncident',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('BehaviorIncident PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.behaviorIncident.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Incident not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.behaviorIncident.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'BehaviorIncident',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
studentId: existing.studentId,
|
||||
categoryId: existing.categoryId,
|
||||
date: existing.date.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('BehaviorIncident DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const severityEnum = z.enum(['minor', 'moderate', 'major']);
|
||||
|
||||
const createIncidentSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
classGroupId: z.string().min(1).optional().nullable(),
|
||||
schoolId: z.string().min(1),
|
||||
categoryId: z.string().min(1),
|
||||
date: z.string().min(1),
|
||||
severity: severityEnum,
|
||||
description: z.string().min(1).max(2000),
|
||||
location: z.string().max(200).optional().nullable(),
|
||||
followUpAction: z.string().max(200).optional().nullable(),
|
||||
resolved: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const categoryId = searchParams.get('categoryId');
|
||||
const severity = searchParams.get('severity');
|
||||
const resolved = searchParams.get('resolved');
|
||||
const teacherId = searchParams.get('teacherId');
|
||||
|
||||
// Determine effective schoolId
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
if (studentId) where.studentId = studentId;
|
||||
if (classGroupId && classGroupId !== 'all') where.classGroupId = classGroupId;
|
||||
if (categoryId && categoryId !== 'all') where.categoryId = categoryId;
|
||||
if (severity && severity !== 'all') where.severity = severity;
|
||||
if (teacherId) where.teacherId = teacherId;
|
||||
if (resolved === 'true') where.resolved = true;
|
||||
if (resolved === 'false') where.resolved = false;
|
||||
if (dateFrom || dateTo) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
where.date = dateFilter;
|
||||
}
|
||||
|
||||
const incidents = await db.behaviorIncident.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
category: {
|
||||
select: { id: true, name: true, color: true, valence: true, icon: true },
|
||||
},
|
||||
resolvedBy: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(incidents);
|
||||
} catch (error) {
|
||||
console.error('BehaviorIncidents GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createIncidentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, studentId, categoryId, classGroupId, date, ...rest } = parsed.data;
|
||||
|
||||
// Verify school
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
school.id !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify student belongs to same school and is not deleted
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { id: true, schoolId: true, deletedAt: true },
|
||||
});
|
||||
if (!student || student.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Student not found in this school' }, { status: 404 });
|
||||
}
|
||||
if (student.deletedAt) {
|
||||
return NextResponse.json({ error: 'Student is deleted' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify category belongs to same school
|
||||
const category = await db.behaviorCategory.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!category || category.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify classGroup belongs to same school (if provided)
|
||||
if (classGroupId) {
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!classGroup || classGroup.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Class group not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = parsed.data.resolved ?? false;
|
||||
const incident = await db.behaviorIncident.create({
|
||||
data: {
|
||||
studentId,
|
||||
teacherId: session.userId,
|
||||
classGroupId: classGroupId || null,
|
||||
schoolId,
|
||||
categoryId,
|
||||
date: new Date(date),
|
||||
severity: parsed.data.severity,
|
||||
description: parsed.data.description,
|
||||
location: parsed.data.location ?? null,
|
||||
followUpAction: parsed.data.followUpAction ?? null,
|
||||
resolved,
|
||||
resolvedAt: resolved ? new Date() : null,
|
||||
resolvedById: resolved ? session.userId : null,
|
||||
},
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
category: { select: { id: true, name: true, color: true, valence: true, icon: true } },
|
||||
resolvedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'BehaviorIncident',
|
||||
entityId: incident.id,
|
||||
metadata: JSON.stringify({
|
||||
studentId,
|
||||
categoryId,
|
||||
severity: parsed.data.severity,
|
||||
date,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(incident, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('BehaviorIncidents POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
type EventType = 'assessment' | 'attendance' | 'progress' | 'report' | 'lesson';
|
||||
|
||||
interface CalendarEvent {
|
||||
date: string; // YYYY-MM-DD
|
||||
type: EventType;
|
||||
id: string;
|
||||
title: string;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Format a Date (or ISO string) as YYYY-MM-DD in UTC to avoid TZ drift. */
|
||||
function toDayKey(value: Date | string): string {
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const monthParam = searchParams.get('month'); // YYYY-MM
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'schoolId is required (pass as query param or have a school assigned to user)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse month, default to current month
|
||||
let year: number;
|
||||
let monthIndex: number; // 0-based
|
||||
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
||||
const [yStr, mStr] = monthParam.split('-');
|
||||
year = parseInt(yStr, 10);
|
||||
monthIndex = parseInt(mStr, 10) - 1;
|
||||
} else {
|
||||
const now = new Date();
|
||||
year = now.getUTCFullYear();
|
||||
monthIndex = now.getUTCMonth();
|
||||
}
|
||||
|
||||
// Build a UTC-based month range so it's timezone-stable
|
||||
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 dateRange = { gte: startOfMonth, lte: endOfMonth };
|
||||
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
/* ── Assessments ──────────────────────────────────────────────── */
|
||||
try {
|
||||
const assessments = await db.assessment.findMany({
|
||||
where: {
|
||||
date: dateRange,
|
||||
classGroup: { schoolId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
type: true,
|
||||
maxScore: true,
|
||||
weight: true,
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (const a of assessments) {
|
||||
events.push({
|
||||
date: toDayKey(a.date),
|
||||
type: 'assessment',
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
meta: {
|
||||
assessmentType: a.type,
|
||||
classGroup: a.classGroup?.name ?? null,
|
||||
subject: a.subject?.name ?? null,
|
||||
maxScore: a.maxScore ?? null,
|
||||
weight: a.weight,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calendar: assessments query failed:', err);
|
||||
}
|
||||
|
||||
/* ── Attendance Sessions ──────────────────────────────────────── */
|
||||
try {
|
||||
const sessions = await db.attendanceSession.findMany({
|
||||
where: {
|
||||
date: dateRange,
|
||||
classGroup: { schoolId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
status: true,
|
||||
period: true,
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
records: {
|
||||
select: { status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (const s of sessions) {
|
||||
const counts: Record<string, number> = {
|
||||
PRESENT: 0,
|
||||
ABSENT: 0,
|
||||
EXCUSED: 0,
|
||||
LATE: 0,
|
||||
};
|
||||
for (const r of s.records) {
|
||||
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
||||
}
|
||||
const total = s.records.length;
|
||||
const present = counts.PRESENT ?? 0;
|
||||
const rate = total > 0 ? Math.round((present / total) * 100) : null;
|
||||
|
||||
events.push({
|
||||
date: toDayKey(s.date),
|
||||
type: 'attendance',
|
||||
id: s.id,
|
||||
title: `${s.classGroup?.name ?? ''} ${t_attendance(s)}`.trim(),
|
||||
meta: {
|
||||
classGroup: s.classGroup?.name ?? null,
|
||||
subject: s.subject?.name ?? null,
|
||||
period: s.period ?? null,
|
||||
sessionStatus: s.status,
|
||||
counts,
|
||||
total,
|
||||
present,
|
||||
rate,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calendar: attendance query failed:', err);
|
||||
}
|
||||
|
||||
/* ── Learning Progress Entries (aggregated by day) ───────────── */
|
||||
try {
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
createdAt: dateRange,
|
||||
classGroup: { schoolId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
masteryLevelValue: true,
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Aggregate by day + class group for nicer chips
|
||||
const progressByDay = new Map<string, { count: number; classGroups: Set<string> }>();
|
||||
for (const e of progressEntries) {
|
||||
const key = toDayKey(e.createdAt);
|
||||
if (!key) continue;
|
||||
const slot = progressByDay.get(key) ?? { count: 0, classGroups: new Set<string>() };
|
||||
slot.count += 1;
|
||||
if (e.classGroup?.name) slot.classGroups.add(e.classGroup.name);
|
||||
progressByDay.set(key, slot);
|
||||
}
|
||||
|
||||
for (const [key, slot] of progressByDay.entries()) {
|
||||
events.push({
|
||||
date: key,
|
||||
type: 'progress',
|
||||
id: `progress-${key}`,
|
||||
title: `${slot.count} ${slot.count === 1 ? 'Eintrag' : 'Einträge'}`,
|
||||
meta: {
|
||||
count: slot.count,
|
||||
classGroups: Array.from(slot.classGroups),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calendar: progress query failed:', err);
|
||||
}
|
||||
|
||||
/* ── Reports ──────────────────────────────────────────────────── */
|
||||
try {
|
||||
const reports = await db.report.findMany({
|
||||
where: {
|
||||
generatedAt: dateRange,
|
||||
classGroup: { schoolId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
generatedAt: true,
|
||||
period: true,
|
||||
status: true,
|
||||
includesGrades: true,
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { generatedAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const r of reports) {
|
||||
const studentName = r.student
|
||||
? `${r.student.firstName} ${r.student.lastName}`
|
||||
: '—';
|
||||
events.push({
|
||||
date: toDayKey(r.generatedAt),
|
||||
type: 'report',
|
||||
id: r.id,
|
||||
title: `${studentName} · ${r.period}`,
|
||||
meta: {
|
||||
studentName,
|
||||
classGroup: r.classGroup?.name ?? null,
|
||||
period: r.period,
|
||||
status: r.status,
|
||||
includesGrades: r.includesGrades,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Calendar: reports query failed:', err);
|
||||
}
|
||||
|
||||
/* ── Lessons (LessonPlan) — try/catch, model may not exist yet ─ */
|
||||
try {
|
||||
// The LessonPlan model is added by another agent concurrently.
|
||||
// Use runtime access so this route still compiles & runs even if the
|
||||
// Prisma Client hasn't been regenerated yet.
|
||||
const lessonPlanClient = (db as unknown as {
|
||||
lessonPlan?: {
|
||||
findMany: (args: Record<string, unknown>) => Promise<Array<Record<string, unknown>>>;
|
||||
};
|
||||
}).lessonPlan;
|
||||
|
||||
if (lessonPlanClient && typeof lessonPlanClient.findMany === 'function') {
|
||||
const lessons = await lessonPlanClient.findMany({
|
||||
where: {
|
||||
date: dateRange,
|
||||
classGroup: { schoolId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
status: true,
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
for (const l of lessons) {
|
||||
const dateVal = l.date as Date | string;
|
||||
const classGroupName =
|
||||
(l.classGroup as { name?: string } | null | undefined)?.name ?? null;
|
||||
const subjectName =
|
||||
(l.subject as { name?: string } | null | undefined)?.name ?? null;
|
||||
events.push({
|
||||
date: toDayKey(dateVal),
|
||||
type: 'lesson',
|
||||
id: String(l.id),
|
||||
title: String(l.title ?? 'Unterricht'),
|
||||
meta: {
|
||||
classGroup: classGroupName,
|
||||
subject: subjectName,
|
||||
status: l.status ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// LessonPlan model doesn't exist yet — gracefully return empty.
|
||||
console.info('Calendar: LessonPlan not available yet, skipping lessons.');
|
||||
}
|
||||
|
||||
// Stable ordering: by date asc, then by type for deterministic display
|
||||
const typeOrder: Record<EventType, number> = {
|
||||
lesson: 0,
|
||||
assessment: 1,
|
||||
attendance: 2,
|
||||
progress: 3,
|
||||
report: 4,
|
||||
};
|
||||
events.sort((a, b) => {
|
||||
if (a.date !== b.date) return a.date.localeCompare(b.date);
|
||||
return typeOrder[a.type] - typeOrder[b.type];
|
||||
});
|
||||
|
||||
return NextResponse.json({ events, month: `${year}-${String(monthIndex + 1).padStart(2, '0')}` });
|
||||
} catch (error) {
|
||||
console.error('Calendar GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tiny helper so we don't import i18n on the server. */
|
||||
function t_attendance(s: { period?: string | null; subject?: { name?: string } | null }): string {
|
||||
const parts: string[] = [];
|
||||
if (s.subject?.name) parts.push(s.subject.name);
|
||||
if (s.period) parts.push(s.period);
|
||||
return parts.length ? `· ${parts.join(' ')}` : 'Anwesenheit';
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createAssignmentSchema = z.object({
|
||||
classGroupId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
competencyTemplateId: z.string().min(1),
|
||||
schoolYearId: z.string().min(1),
|
||||
clonedTemplateId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
if (schoolYearId) where.schoolYearId = schoolYearId;
|
||||
|
||||
const assignments = await db.classCompetencyAssignment.findMany({
|
||||
where,
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
competencyTemplate: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
schoolType: true,
|
||||
gradeLevelMin: true,
|
||||
gradeLevelMax: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(assignments);
|
||||
} catch (error) {
|
||||
console.error('ClassCompetencyAssignments GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createAssignmentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check uniqueness constraint (classGroupId + subjectId + schoolYearId)
|
||||
const existing = await db.classCompetencyAssignment.findFirst({
|
||||
where: {
|
||||
classGroupId: parsed.data.classGroupId,
|
||||
subjectId: parsed.data.subjectId,
|
||||
schoolYearId: parsed.data.schoolYearId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A competency template is already assigned for this class/subject/year combination' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const assignment = await db.classCompetencyAssignment.create({
|
||||
data: parsed.data,
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
competencyTemplate: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(assignment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ClassCompetencyAssignments POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'id query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.classCompetencyAssignment.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ message: 'Assignment deleted' });
|
||||
} catch (error) {
|
||||
console.error('ClassCompetencyAssignments DELETE error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: classGroupId } = await params;
|
||||
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId,
|
||||
endDate: null,
|
||||
student: { deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
student: true,
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
orderBy: { student: { lastName: 'asc' } },
|
||||
});
|
||||
|
||||
const students = enrollments.map((e) => ({
|
||||
...e.student,
|
||||
enrollmentId: e.id,
|
||||
schoolYearId: e.schoolYearId,
|
||||
schoolYear: e.schoolYear,
|
||||
startDate: e.startDate,
|
||||
}));
|
||||
|
||||
return NextResponse.json(students);
|
||||
} catch (error) {
|
||||
console.error('Class students GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id: classGroupId } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const schema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
schoolYearId: z.string().min(1),
|
||||
startDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v) : new Date())),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { studentId, schoolYearId, startDate } = parsed.data;
|
||||
|
||||
// Check if already enrolled
|
||||
const existing = await db.enrollment.findFirst({
|
||||
where: {
|
||||
studentId,
|
||||
classGroupId,
|
||||
endDate: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Student already enrolled in this class' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const enrollment = await db.enrollment.create({
|
||||
data: {
|
||||
studentId,
|
||||
classGroupId,
|
||||
schoolYearId,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
student: true,
|
||||
classGroup: true,
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(enrollment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Class students POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createClassSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
schoolYearId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
gradeLevel: z.number().int().min(1).max(13),
|
||||
schoolType: z
|
||||
.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'])
|
||||
.default('ELEMENTARY'),
|
||||
teacherIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
if (schoolYearId) where.schoolYearId = schoolYearId;
|
||||
|
||||
// If teacher role, only show their assigned classes
|
||||
if (session.user?.role === 'TEACHER' && session.userId) {
|
||||
where.teachers = { some: { userId: session.userId } };
|
||||
}
|
||||
|
||||
const classes = await db.classGroup.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
teachers: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = classes.map((cls) => ({
|
||||
...cls,
|
||||
studentCount: cls._count.enrollments,
|
||||
teacherList: cls.teachers.map((t) => ({
|
||||
...t.user,
|
||||
teacherRole: t.role,
|
||||
})),
|
||||
}));
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Classes GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createClassSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { teacherIds, ...classData } = parsed.data;
|
||||
|
||||
const classGroup = await db.classGroup.create({
|
||||
data: {
|
||||
...classData,
|
||||
teachers: teacherIds
|
||||
? {
|
||||
create: teacherIds.map((userId) => ({
|
||||
userId,
|
||||
role: 'SUBJECT_TEACHER' as const,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
teachers: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(classGroup, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Classes POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const updateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
gradeLevel: z.number().int().min(1).max(13).optional(),
|
||||
schoolType: z
|
||||
.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, ...updateData } = parsed.data;
|
||||
|
||||
const classGroup = await db.classGroup.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
teachers: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(classGroup);
|
||||
} catch (error) {
|
||||
console.error('Classes PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateEntrySchema = z.object({
|
||||
categoryId: z.string().min(1).optional(),
|
||||
subjectId: z.string().min(1).optional().nullable(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
text: z.string().min(1).max(5000).optional(),
|
||||
gradeLevel: z.string().max(50).optional().nullable(),
|
||||
schoolType: z.string().max(50).optional().nullable(),
|
||||
isPublic: z.boolean().optional(),
|
||||
tags: z.string().max(500).optional().nullable(),
|
||||
incrementUsage: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const entry = await db.commentBankEntry.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
category: { select: { id: true, name: true, color: true, icon: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
entry.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
console.error('CommentBankEntry GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.commentBankEntry.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (parsed.data.categoryId && parsed.data.categoryId !== existing.categoryId) {
|
||||
const category = await db.commentCategory.findUnique({
|
||||
where: { id: parsed.data.categoryId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!category || category.schoolId !== existing.schoolId) {
|
||||
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.subjectId) {
|
||||
const subject = await db.subject.findUnique({
|
||||
where: { id: parsed.data.subjectId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!subject || (subject.schoolId && subject.schoolId !== existing.schoolId)) {
|
||||
return NextResponse.json({ error: 'Subject not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.categoryId !== undefined) data.categoryId = parsed.data.categoryId;
|
||||
if (parsed.data.subjectId !== undefined) data.subjectId = parsed.data.subjectId || null;
|
||||
if (parsed.data.title !== undefined) data.title = parsed.data.title;
|
||||
if (parsed.data.text !== undefined) data.text = parsed.data.text;
|
||||
if (parsed.data.gradeLevel !== undefined) data.gradeLevel = parsed.data.gradeLevel ?? null;
|
||||
if (parsed.data.schoolType !== undefined) data.schoolType = parsed.data.schoolType ?? null;
|
||||
if (parsed.data.isPublic !== undefined) data.isPublic = parsed.data.isPublic;
|
||||
if (parsed.data.tags !== undefined) data.tags = parsed.data.tags ?? null;
|
||||
|
||||
// Increment usageCount if requested
|
||||
if (parsed.data.incrementUsage) {
|
||||
data.usageCount = existing.usageCount + 1;
|
||||
}
|
||||
|
||||
const updated = await db.commentBankEntry.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
category: { select: { id: true, name: true, color: true, icon: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'CommentBankEntry',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('CommentBankEntry PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.commentBankEntry.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Entry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.commentBankEntry.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'CommentBankEntry',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ title: existing.title }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('CommentBankEntry DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createEntrySchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
categoryId: z.string().min(1),
|
||||
subjectId: z.string().min(1).optional().nullable(),
|
||||
title: z.string().min(1).max(200),
|
||||
text: z.string().min(1).max(5000),
|
||||
gradeLevel: z.string().max(50).optional().nullable(),
|
||||
schoolType: z.string().max(50).optional().nullable(),
|
||||
isPublic: z.boolean().optional(),
|
||||
tags: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
const teacherId = searchParams.get('teacherId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const categoryId = searchParams.get('categoryId');
|
||||
const search = searchParams.get('search');
|
||||
const gradeLevel = searchParams.get('gradeLevel');
|
||||
const isPublic = searchParams.get('isPublic');
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { schoolId };
|
||||
|
||||
// Show public comments + own comments
|
||||
if (teacherId) {
|
||||
where.teacherId = teacherId;
|
||||
}
|
||||
|
||||
if (subjectId && subjectId !== 'all') where.subjectId = subjectId;
|
||||
if (categoryId && categoryId !== 'all') where.categoryId = categoryId;
|
||||
if (gradeLevel && gradeLevel !== 'all') where.gradeLevel = gradeLevel;
|
||||
if (isPublic === 'true') where.isPublic = true;
|
||||
if (isPublic === 'false') where.isPublic = false;
|
||||
|
||||
// Full-text search on title, text, tags
|
||||
if (search && search.trim()) {
|
||||
const q = search.trim();
|
||||
where.OR = [
|
||||
{ title: { contains: q } },
|
||||
{ text: { contains: q } },
|
||||
{ tags: { contains: q } },
|
||||
];
|
||||
}
|
||||
|
||||
const entries = await db.commentBankEntry.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
category: { select: { id: true, name: true, color: true, icon: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entries);
|
||||
} catch (error) {
|
||||
console.error('CommentBank GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, categoryId, subjectId } = parsed.data;
|
||||
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
school.id !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const category = await db.commentCategory.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!category || category.schoolId !== schoolId) {
|
||||
return NextResponse.json({ error: 'Category not found in this school' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (subjectId) {
|
||||
const subject = await db.subject.findUnique({
|
||||
where: { id: subjectId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!subject || (subject.schoolId && subject.schoolId !== schoolId)) {
|
||||
return NextResponse.json({ error: 'Subject not found in this school' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await db.commentBankEntry.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
categoryId,
|
||||
subjectId: subjectId || null,
|
||||
title: parsed.data.title,
|
||||
text: parsed.data.text,
|
||||
gradeLevel: parsed.data.gradeLevel ?? null,
|
||||
schoolType: parsed.data.schoolType ?? null,
|
||||
isPublic: parsed.data.isPublic ?? false,
|
||||
tags: parsed.data.tags ?? null,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
category: { select: { id: true, name: true, color: true, icon: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'CommentBankEntry',
|
||||
entityId: entry.id,
|
||||
metadata: JSON.stringify({ title: parsed.data.title, categoryId }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entry, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('CommentBank POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981')
|
||||
.optional(),
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const category = await db.commentCategory.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { comments: true } } },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
category.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(category);
|
||||
} catch (error) {
|
||||
console.error('CommentCategory GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateCategorySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.commentCategory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (parsed.data.name && parsed.data.name !== existing.name) {
|
||||
const dupe = await db.commentCategory.findUnique({
|
||||
where: { schoolId_name: { schoolId: existing.schoolId, name: parsed.data.name } },
|
||||
});
|
||||
if (dupe) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A category with this name already exists in this school' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||
if (parsed.data.color !== undefined) data.color = parsed.data.color;
|
||||
if (parsed.data.icon !== undefined) data.icon = parsed.data.icon ?? null;
|
||||
|
||||
const updated = await db.commentCategory.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'CommentCategory',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('CommentCategory PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.commentCategory.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { comments: true } } },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (existing._count.comments > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot delete: category is referenced by existing comments',
|
||||
commentCount: existing._count.comments,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.commentCategory.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'CommentCategory',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ name: existing.name, color: existing.color }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('CommentCategory DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createCategorySchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'color must be a hex like #10b981'),
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
});
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const categories = await db.commentCategory.findMany({
|
||||
where: { schoolId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(categories);
|
||||
} catch (error) {
|
||||
console.error('CommentCategories GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createCategorySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, name } = parsed.data;
|
||||
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
school.id !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const existing = await db.commentCategory.findUnique({
|
||||
where: { schoolId_name: { schoolId, name } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A category with this name already exists in this school' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const category = await db.commentCategory.create({
|
||||
data: {
|
||||
schoolId,
|
||||
name,
|
||||
color: parsed.data.color,
|
||||
icon: parsed.data.icon ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'CommentCategory',
|
||||
entityId: category.id,
|
||||
metadata: JSON.stringify({ name, color: parsed.data.color }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(category, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('CommentCategories POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
|
||||
if (!studentId && !classGroupId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'studentId or classGroupId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the competency template assigned to this class + subject
|
||||
if (!classGroupId || !subjectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'classGroupId and subjectId are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const assignment = await db.classCompetencyAssignment.findFirst({
|
||||
where: { classGroupId, subjectId },
|
||||
include: {
|
||||
competencyTemplate: {
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No competency template assigned for this class/subject' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const template = assignment.competencyTemplate;
|
||||
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
|
||||
|
||||
// Re-fetch with full template if cloned
|
||||
let categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
order: number;
|
||||
competencies: Array<{ id: string; code: string; title: string; order: number }>;
|
||||
}>;
|
||||
|
||||
if (assignment.clonedTemplateId) {
|
||||
const clonedTemplate = await db.competencyTemplate.findUnique({
|
||||
where: { id: assignment.clonedTemplateId },
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true, code: true, title: true, order: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
categories = clonedTemplate?.categories ?? template.categories;
|
||||
} else {
|
||||
categories = template.categories;
|
||||
}
|
||||
|
||||
// Get student IDs to query
|
||||
let studentIds: string[];
|
||||
if (studentId) {
|
||||
studentIds = [studentId];
|
||||
} else {
|
||||
// Get all students in the class
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
studentIds = enrollments.map((e) => e.studentId);
|
||||
}
|
||||
|
||||
// Fetch latest progress entries for each competency for each student
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
studentId: { in: studentIds },
|
||||
classGroupId,
|
||||
competency: {
|
||||
category: {
|
||||
competencyTemplateId: templateId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
categoryId: true,
|
||||
code: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by student, then compute per-category averages
|
||||
// For each student, find the latest mastery level per competency, then average per category
|
||||
const studentMap = new Map<
|
||||
string,
|
||||
Map<string, number>
|
||||
>(); // studentId -> (competencyId -> latest masteryLevelValue)
|
||||
|
||||
for (const entry of progressEntries) {
|
||||
const sid = entry.studentId;
|
||||
if (!studentMap.has(sid)) {
|
||||
studentMap.set(sid, new Map());
|
||||
}
|
||||
const compMap = studentMap.get(sid)!;
|
||||
// Only keep the latest entry per competency (they are ordered by date desc)
|
||||
if (!compMap.has(entry.competencyId)) {
|
||||
compMap.set(entry.competencyId, entry.masteryLevelValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the result
|
||||
const result: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
categories: Array<{
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
color: string | null;
|
||||
averageMasteryLevel: number;
|
||||
competencyCount: number;
|
||||
assessedCompetencyCount: number;
|
||||
}>;
|
||||
}> = [];
|
||||
|
||||
// Get student names
|
||||
const students = await db.student.findMany({
|
||||
where: { id: { in: studentIds }, deletedAt: null },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
|
||||
const studentNameMap = new Map(
|
||||
students.map((s) => [s.id, `${s.firstName} ${s.lastName}`])
|
||||
);
|
||||
|
||||
for (const [sid, compMap] of studentMap) {
|
||||
const categoryResults = categories.map((cat) => {
|
||||
const catCompetencies = cat.competencies;
|
||||
const assessedValues = catCompetencies
|
||||
.filter((c) => compMap.has(c.id))
|
||||
.map((c) => compMap.get(c.id)!);
|
||||
|
||||
const avg =
|
||||
assessedValues.length > 0
|
||||
? assessedValues.reduce((a, b) => a + b, 0) / assessedValues.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
color: cat.color,
|
||||
averageMasteryLevel: Math.round(avg * 100) / 100,
|
||||
competencyCount: catCompetencies.length,
|
||||
assessedCompetencyCount: assessedValues.length,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({
|
||||
studentId: sid,
|
||||
studentName: studentNameMap.get(sid) ?? 'Unknown',
|
||||
categories: categoryResults,
|
||||
});
|
||||
}
|
||||
|
||||
// Also include students with no entries yet
|
||||
for (const sid of studentIds) {
|
||||
if (!studentMap.has(sid)) {
|
||||
const categoryResults = categories.map((cat) => ({
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
color: cat.color,
|
||||
averageMasteryLevel: 0,
|
||||
competencyCount: cat.competencies.length,
|
||||
assessedCompetencyCount: 0,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
studentId: sid,
|
||||
studentName: studentNameMap.get(sid) ?? 'Unknown',
|
||||
categories: categoryResults,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('CompetenceFlower GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(),
|
||||
gradeLevelMin: z.number().int().optional(),
|
||||
gradeLevelMax: z.number().int().optional(),
|
||||
isGlobalTemplate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const template = await db.competencyTemplate.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
school: { select: { id: true, name: true } },
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
classCompetencyAssignments: {
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(template);
|
||||
} catch (error) {
|
||||
console.error('CompetencyTemplate GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateTemplateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const template = await db.competencyTemplate.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(template);
|
||||
} catch (error) {
|
||||
console.error('CompetencyTemplate PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Check if template is assigned to any class
|
||||
const assignments = await db.classCompetencyAssignment.findFirst({
|
||||
where: { competencyTemplateId: id },
|
||||
});
|
||||
|
||||
if (assignments) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template is currently assigned to a class and cannot be deleted' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.competencyTemplate.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ message: 'Template deleted' });
|
||||
} catch (error) {
|
||||
console.error('CompetencyTemplate DELETE error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const masteryLevelSchema = z.object({
|
||||
levelValue: z.number().int(),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const competencySchema = z.object({
|
||||
code: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
order: z.number().int().default(0),
|
||||
masteryLevels: z.array(masteryLevelSchema).optional(),
|
||||
});
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
order: z.number().int().default(0),
|
||||
color: z.string().optional(),
|
||||
competencies: z.array(competencySchema).optional(),
|
||||
});
|
||||
|
||||
const createTemplateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).default('ELEMENTARY'),
|
||||
gradeLevelMin: z.number().int().default(1),
|
||||
gradeLevelMax: z.number().int().default(4),
|
||||
isGlobalTemplate: z.boolean().default(false),
|
||||
schoolId: z.string().optional(),
|
||||
categories: z.array(categorySchema).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolType = searchParams.get('schoolType');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const gradeLevel = searchParams.get('gradeLevel');
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (schoolType) where.schoolType = schoolType;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
if (gradeLevel) {
|
||||
const gl = parseInt(gradeLevel, 10);
|
||||
if (!isNaN(gl)) {
|
||||
where.AND = [
|
||||
{ gradeLevelMin: { lte: gl } },
|
||||
{ gradeLevelMax: { gte: gl } },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Show global templates + school-specific ones
|
||||
if (schoolId) {
|
||||
where.OR = [
|
||||
{ isGlobalTemplate: true },
|
||||
{ schoolId },
|
||||
];
|
||||
}
|
||||
|
||||
const templates = await db.competencyTemplate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
school: { select: { id: true, name: true } },
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { classCompetencyAssignments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(templates);
|
||||
} catch (error) {
|
||||
console.error('CompetencyTemplates GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createTemplateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { categories, ...templateData } = parsed.data;
|
||||
|
||||
const template = await db.competencyTemplate.create({
|
||||
data: {
|
||||
name: templateData.name,
|
||||
description: templateData.description,
|
||||
subjectId: templateData.subjectId ?? null,
|
||||
schoolType: templateData.schoolType,
|
||||
gradeLevelMin: templateData.gradeLevelMin,
|
||||
gradeLevelMax: templateData.gradeLevelMax,
|
||||
isGlobalTemplate: templateData.isGlobalTemplate,
|
||||
schoolId: templateData.schoolId ?? null,
|
||||
createdByUserId: session.userId,
|
||||
categories: categories
|
||||
? {
|
||||
create: categories.map((cat) => ({
|
||||
name: cat.name,
|
||||
order: cat.order,
|
||||
color: cat.color,
|
||||
competencies: cat.competencies
|
||||
? {
|
||||
create: cat.competencies.map((comp) => ({
|
||||
code: comp.code,
|
||||
title: comp.title,
|
||||
description: comp.description,
|
||||
order: comp.order,
|
||||
masteryLevelDefinitions: comp.masteryLevels
|
||||
? {
|
||||
create: comp.masteryLevels.map((ml) => ({
|
||||
levelValue: ml.levelValue,
|
||||
label: ml.label,
|
||||
description: ml.description,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
masteryLevelDefinitions: { orderBy: { levelValue: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(template, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('CompetencyTemplates POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
|
||||
if (!classGroupId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'classGroupId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify access: must be admin of the school OR a teacher of the class
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gradeLevel: true,
|
||||
schoolType: true,
|
||||
schoolId: true,
|
||||
schoolYearId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!classGroup) {
|
||||
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'SCHOOL_ADMIN' || session.user.schoolId !== classGroup.schoolId) {
|
||||
if (session.user?.role === 'TEACHER') {
|
||||
const teacherLink = await db.classGroupTeacher.findFirst({
|
||||
where: { classGroupId, userId: session.userId },
|
||||
});
|
||||
if (!teacherLink && session.user.schoolId !== classGroup.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all assignments for this class (optionally filtered by subject)
|
||||
const assignmentWhere: { classGroupId: string; subjectId?: string } = { classGroupId };
|
||||
if (subjectId) assignmentWhere.subjectId = subjectId;
|
||||
|
||||
const assignments = await db.classCompetencyAssignment.findMany({
|
||||
where: assignmentWhere,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
competencyTemplate: {
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
categoryId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return NextResponse.json({
|
||||
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
|
||||
subject: null,
|
||||
totals: {
|
||||
competencies: 0,
|
||||
assessed: 0,
|
||||
notAssessed: 0,
|
||||
coveragePercent: 0,
|
||||
studentsCount: 0,
|
||||
assessmentsCount: 0,
|
||||
progressEntriesCount: 0,
|
||||
},
|
||||
byCategory: [],
|
||||
byCompetency: [],
|
||||
notAssessedList: [],
|
||||
recentAssessed: [],
|
||||
hasAssignments: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregate all competencies across all assignments
|
||||
const competencySet = new Map<string, { id: string; code: string; title: string; categoryId: string; categoryName: string; categoryColor: string | null; subjectName: string }>();
|
||||
const categoryMap = new Map<string, { id: string; name: string; color: string | null }>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const template = assignment.competencyTemplate;
|
||||
for (const category of template.categories) {
|
||||
if (!categoryMap.has(category.id)) {
|
||||
categoryMap.set(category.id, { id: category.id, name: category.name, color: category.color });
|
||||
}
|
||||
for (const comp of category.competencies) {
|
||||
if (!competencySet.has(comp.id)) {
|
||||
competencySet.set(comp.id, {
|
||||
id: comp.id,
|
||||
code: comp.code,
|
||||
title: comp.title,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
categoryColor: category.color,
|
||||
subjectName: assignment.subject?.name ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const competencyIds = Array.from(competencySet.keys());
|
||||
|
||||
// Count students enrolled in this class
|
||||
const studentsCount = await db.enrollment.count({
|
||||
where: { classGroupId, endDate: null },
|
||||
});
|
||||
|
||||
if (competencyIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
|
||||
subject: subjectId ? assignments[0]?.subject : null,
|
||||
totals: {
|
||||
competencies: 0,
|
||||
assessed: 0,
|
||||
notAssessed: 0,
|
||||
coveragePercent: 0,
|
||||
studentsCount,
|
||||
assessmentsCount: 0,
|
||||
progressEntriesCount: 0,
|
||||
},
|
||||
byCategory: [],
|
||||
byCompetency: [],
|
||||
notAssessedList: [],
|
||||
recentAssessed: [],
|
||||
hasAssignments: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all LearningProgressEntries for this class + these competencies (efficient single query)
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
classGroupId,
|
||||
competencyId: { in: competencyIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
competencyId: true,
|
||||
studentId: true,
|
||||
date: true,
|
||||
masteryLevelValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all AssessmentCompetencyLinks for these competencies (with assessment + results for this class)
|
||||
const assessmentLinks = await db.assessmentCompetencyLink.findMany({
|
||||
where: { competencyId: { in: competencyIds } },
|
||||
select: {
|
||||
competencyId: true,
|
||||
assessmentId: true,
|
||||
assessment: {
|
||||
select: {
|
||||
id: true,
|
||||
classGroupId: true,
|
||||
date: true,
|
||||
assessmentResults: { select: { id: true, studentId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter assessment links to only those assessments for this class
|
||||
const classAssessmentLinks = assessmentLinks.filter((l) => l.assessment.classGroupId === classGroupId);
|
||||
const assessmentsCount = new Set(classAssessmentLinks.map((l) => l.assessmentId)).size;
|
||||
|
||||
// Aggregate per competency
|
||||
const progressByCompetency = new Map<string, { count: number; students: Set<string>; lastDate: Date | null; lastMastery: number | null }>();
|
||||
for (const entry of progressEntries) {
|
||||
const cur = progressByCompetency.get(entry.competencyId) ?? { count: 0, students: new Set<string>(), lastDate: null, lastMastery: null };
|
||||
cur.count += 1;
|
||||
cur.students.add(entry.studentId);
|
||||
if (!cur.lastDate || entry.date > cur.lastDate) {
|
||||
cur.lastDate = entry.date;
|
||||
cur.lastMastery = entry.masteryLevelValue;
|
||||
}
|
||||
progressByCompetency.set(entry.competencyId, cur);
|
||||
}
|
||||
|
||||
const assessmentByCompetency = new Map<string, { count: number; students: Set<string> }>();
|
||||
for (const link of classAssessmentLinks) {
|
||||
const cur = assessmentByCompetency.get(link.competencyId) ?? { count: 0, students: new Set<string>() };
|
||||
cur.count += link.assessment.assessmentResults.length;
|
||||
for (const r of link.assessment.assessmentResults) cur.students.add(r.studentId);
|
||||
assessmentByCompetency.set(link.competencyId, cur);
|
||||
}
|
||||
|
||||
// Build byCompetency list
|
||||
const byCompetency = Array.from(competencySet.values()).map((c) => {
|
||||
const prog = progressByCompetency.get(c.id);
|
||||
const assess = assessmentByCompetency.get(c.id);
|
||||
const progressCount = prog?.count ?? 0;
|
||||
const assessmentCount = assess?.count ?? 0;
|
||||
// Deduplicate students (in case same student is in both)
|
||||
const studentSet = new Set<string>();
|
||||
prog?.students.forEach((s) => studentSet.add(s));
|
||||
assess?.students.forEach((s) => studentSet.add(s));
|
||||
const uniqueStudentsAssessed = studentSet.size;
|
||||
const lastAssessedDate = prog?.lastDate ?? null;
|
||||
|
||||
let status: 'covered' | 'partial' | 'untouched' = 'untouched';
|
||||
if (studentsCount > 0) {
|
||||
const ratio = uniqueStudentsAssessed / studentsCount;
|
||||
if (ratio >= 0.8) status = 'covered';
|
||||
else if (ratio > 0) status = 'partial';
|
||||
} else if (uniqueStudentsAssessed > 0) {
|
||||
status = 'covered';
|
||||
}
|
||||
|
||||
return {
|
||||
competencyId: c.id,
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
categoryName: c.categoryName,
|
||||
categoryColor: c.categoryColor ?? '#10b981',
|
||||
assessmentCount,
|
||||
progressCount,
|
||||
studentsAssessed: uniqueStudentsAssessed,
|
||||
lastAssessedDate: lastAssessedDate ? lastAssessedDate.toISOString() : null,
|
||||
lastMasteryLevel: prog?.lastMastery ?? null,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
// byCategory aggregation (assessed = covered + partial, notAssessed = untouched)
|
||||
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; categoryColor: string; total: number; assessed: number; notAssessed: number }>();
|
||||
for (const c of byCompetency) {
|
||||
const cur = byCategoryMap.get(c.categoryName) ?? {
|
||||
categoryId: c.categoryName,
|
||||
categoryName: c.categoryName,
|
||||
categoryColor: c.categoryColor,
|
||||
total: 0,
|
||||
assessed: 0,
|
||||
notAssessed: 0,
|
||||
};
|
||||
cur.total += 1;
|
||||
if (c.status === 'covered' || c.status === 'partial') cur.assessed += 1;
|
||||
else cur.notAssessed += 1;
|
||||
byCategoryMap.set(c.categoryName, cur);
|
||||
}
|
||||
const byCategory = Array.from(byCategoryMap.values()).map((b) => ({
|
||||
...b,
|
||||
coveragePercent: b.total > 0 ? Math.round((b.assessed / b.total) * 100) : 0,
|
||||
}));
|
||||
|
||||
// Totals
|
||||
const assessed = byCompetency.filter((c) => c.status !== 'untouched').length;
|
||||
const notAssessed = byCompetency.length - assessed;
|
||||
const coveragePercent = byCompetency.length > 0 ? Math.round((assessed / byCompetency.length) * 100) : 0;
|
||||
|
||||
// notAssessedList (prioritize untouched, then partial; sort by category name)
|
||||
const notAssessedList = byCompetency
|
||||
.filter((c) => c.status !== 'covered')
|
||||
.sort((a, b) => {
|
||||
const orderA = a.status === 'untouched' ? 0 : 1;
|
||||
const orderB = b.status === 'untouched' ? 0 : 1;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.categoryName.localeCompare(b.categoryName);
|
||||
})
|
||||
.slice(0, 20)
|
||||
.map((c) => ({
|
||||
competencyId: c.competencyId,
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
categoryName: c.categoryName,
|
||||
categoryColor: c.categoryColor,
|
||||
status: c.status,
|
||||
studentsAssessed: c.studentsAssessed,
|
||||
studentsCount,
|
||||
}));
|
||||
|
||||
// recentAssessed — 5 most recent
|
||||
const recentAssessed = byCompetency
|
||||
.filter((c) => c.lastAssessedDate)
|
||||
.sort((a, b) => new Date(b.lastAssessedDate!).getTime() - new Date(a.lastAssessedDate!).getTime())
|
||||
.slice(0, 5)
|
||||
.map((c) => ({
|
||||
competencyId: c.competencyId,
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
categoryName: c.categoryName,
|
||||
date: c.lastAssessedDate,
|
||||
masteryLevelValue: c.lastMasteryLevel,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
classGroup: { id: classGroup.id, name: classGroup.name, gradeLevel: classGroup.gradeLevel, schoolType: classGroup.schoolType },
|
||||
subject: subjectId ? assignments[0]?.subject ?? null : null,
|
||||
totals: {
|
||||
competencies: byCompetency.length,
|
||||
assessed,
|
||||
notAssessed,
|
||||
coveragePercent,
|
||||
studentsCount,
|
||||
assessmentsCount,
|
||||
progressEntriesCount: progressEntries.length,
|
||||
},
|
||||
byCategory,
|
||||
byCompetency,
|
||||
notAssessedList,
|
||||
recentAssessed,
|
||||
hasAssignments: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Curriculum coverage API error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'schoolId is required (pass as query param or have a school assigned to user)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Classes Overview ──
|
||||
const classWhere: Record<string, unknown> = { schoolId };
|
||||
if (schoolYearId) classWhere.schoolYearId = schoolYearId;
|
||||
|
||||
// For teachers, only show assigned classes
|
||||
if (session.user?.role === 'TEACHER' && session.userId) {
|
||||
classWhere.teachers = { some: { userId: session.userId } };
|
||||
}
|
||||
|
||||
const classes = await db.classGroup.findMany({
|
||||
where: classWhere,
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
teachers: {
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
enrollments: { where: { endDate: null } },
|
||||
assessments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const classesOverview = classes.map((cls) => ({
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
gradeLevel: cls.gradeLevel,
|
||||
schoolType: cls.schoolType,
|
||||
schoolYear: cls.schoolYear,
|
||||
studentCount: cls._count.enrollments,
|
||||
assessmentCount: cls._count.assessments,
|
||||
teachers: cls.teachers.map((t) => ({
|
||||
...t.user,
|
||||
teacherRole: t.role,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Recent Progress Entries ──
|
||||
const recentEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
category: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Students needing attention ──
|
||||
// Find students with low average mastery (< 2.0 out of 4.0)
|
||||
const studentsWithProgress = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
||||
},
|
||||
select: {
|
||||
studentId: true,
|
||||
masteryLevelValue: true,
|
||||
competencyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate average mastery per student
|
||||
const studentMasteryMap = new Map<string, { total: number; count: number }>();
|
||||
for (const entry of studentsWithProgress) {
|
||||
const current = studentMasteryMap.get(entry.studentId) ?? { total: 0, count: 0 };
|
||||
current.total += entry.masteryLevelValue;
|
||||
current.count += 1;
|
||||
studentMasteryMap.set(entry.studentId, current);
|
||||
}
|
||||
|
||||
const lowMasteryStudentIds = Array.from(studentMasteryMap.entries())
|
||||
.filter(([, data]) => data.count > 0 && data.total / data.count < 2.0)
|
||||
.map(([id]) => id);
|
||||
|
||||
const studentsNeedingAttention = await db.student.findMany({
|
||||
where: {
|
||||
id: { in: lowMasteryStudentIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add average mastery to each student
|
||||
const studentsWithAvg = studentsNeedingAttention.map((s) => {
|
||||
const data = studentMasteryMap.get(s.id);
|
||||
const avgMastery = data ? Math.round(data.total / data.count * 100) / 100 : 0;
|
||||
return {
|
||||
...s,
|
||||
averageMastery: avgMastery,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Stats ──
|
||||
const totalStudents = await db.student.count({
|
||||
where: { schoolId, deletedAt: null },
|
||||
});
|
||||
|
||||
const totalClasses = await db.classGroup.count({
|
||||
where: classWhere,
|
||||
});
|
||||
|
||||
const totalAssessments = await db.assessment.count({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const totalProgressEntries = await db.learningProgressEntry.count({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const totalReports = await db.report.count({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { schoolYearId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const recentAssessments = await db.assessment.findMany({
|
||||
where: {
|
||||
classGroup: { schoolId },
|
||||
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: { assessmentResults: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
classesOverview,
|
||||
recentEntries,
|
||||
recentAssessments,
|
||||
studentsNeedingAttention: studentsWithAvg,
|
||||
stats: {
|
||||
totalStudents,
|
||||
totalClasses,
|
||||
totalAssessments,
|
||||
totalProgressEntries,
|
||||
totalReports,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function escapeCsvField(field: string | number | null | undefined): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
const str = String(field);
|
||||
// Escape fields containing quotes, commas, or newlines
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function toCsvRow(fields: (string | number | null | undefined)[]): string {
|
||||
return fields.map(escapeCsvField).join(',');
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'students';
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
||||
|
||||
let csvContent = '';
|
||||
let filename = `${type}_export.csv`;
|
||||
|
||||
if (type === 'students') {
|
||||
// Students CSV
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
|
||||
let students = await db.student.findMany({
|
||||
where,
|
||||
orderBy: { lastName: 'asc' },
|
||||
include: {
|
||||
school: { select: { name: true } },
|
||||
enrollments: {
|
||||
where: classGroupId ? { classGroupId } : {},
|
||||
include: {
|
||||
classGroup: { select: { name: true, gradeLevel: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { learningProgressEntries: true, assessmentResults: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter by school year if specified
|
||||
if (schoolYearId && !classGroupId) {
|
||||
students = students.filter((s) =>
|
||||
s.enrollments.some((e) => e.schoolYearId === schoolYearId)
|
||||
);
|
||||
} else if (classGroupId) {
|
||||
students = students.filter((s) =>
|
||||
s.enrollments.some((e) => e.classGroupId === classGroupId)
|
||||
);
|
||||
}
|
||||
|
||||
const header = ['ID', 'First Name', 'Last Name', 'Date of Birth', 'External ID', 'School', 'Class', 'Grade Level', 'Progress Entries', 'Assessment Results'];
|
||||
csvContent = header.map(escapeCsvField).join(',') + '\n';
|
||||
|
||||
for (const s of students) {
|
||||
const enrollment = s.enrollments[0];
|
||||
csvContent += toCsvRow([
|
||||
s.id,
|
||||
s.firstName,
|
||||
s.lastName,
|
||||
s.dateOfBirth ? new Date(s.dateOfBirth).toLocaleDateString() : '',
|
||||
s.externalId ?? '',
|
||||
s.school.name,
|
||||
enrollment?.classGroup?.name ?? '',
|
||||
enrollment?.classGroup?.gradeLevel ?? '',
|
||||
s._count.learningProgressEntries,
|
||||
s._count.assessmentResults,
|
||||
]) + '\n';
|
||||
}
|
||||
|
||||
filename = `students_export.csv`;
|
||||
} else if (type === 'progress') {
|
||||
// Progress entries CSV
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
|
||||
let entries = await db.learningProgressEntry.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Filter by school year through classGroup
|
||||
if (schoolYearId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolYearId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
entries = entries.filter((e) => classIds.includes(e.classGroupId));
|
||||
}
|
||||
|
||||
// Filter by school
|
||||
if (schoolId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
entries = entries.filter((e) => classIds.includes(e.classGroupId));
|
||||
}
|
||||
|
||||
const header = ['ID', 'Date', 'Student First Name', 'Student Last Name', 'Student ID', 'Competency Code', 'Competency Title', 'Category', 'Mastery Level', 'Note', 'Teacher', 'Class'];
|
||||
csvContent = header.map(escapeCsvField).join(',') + '\n';
|
||||
|
||||
for (const e of entries) {
|
||||
csvContent += toCsvRow([
|
||||
e.id,
|
||||
new Date(e.date).toLocaleDateString(),
|
||||
e.student.firstName,
|
||||
e.student.lastName,
|
||||
e.student.id,
|
||||
e.competency.code,
|
||||
e.competency.title,
|
||||
e.competency.category.name,
|
||||
e.masteryLevelValue,
|
||||
e.note ?? '',
|
||||
`${e.teacher.firstName} ${e.teacher.lastName}`,
|
||||
e.classGroup.name,
|
||||
]) + '\n';
|
||||
}
|
||||
|
||||
filename = `progress_export.csv`;
|
||||
} else if (type === 'assessments') {
|
||||
// Assessments CSV
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
|
||||
let assessments = await db.assessment.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
assessmentResults: {
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (schoolYearId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolYearId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
assessments = assessments.filter((a) => classIds.includes(a.classGroupId));
|
||||
}
|
||||
|
||||
if (schoolId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
assessments = assessments.filter((a) => classIds.includes(a.classGroupId));
|
||||
}
|
||||
|
||||
const header = ['Assessment ID', 'Title', 'Date', 'Type', 'Subject', 'Class', 'Teacher', 'Max Score', 'Weight', 'Student ID', 'Student Name', 'Score', 'Mastery Level', 'Note'];
|
||||
csvContent = header.map(escapeCsvField).join(',') + '\n';
|
||||
|
||||
for (const a of assessments) {
|
||||
if (a.assessmentResults.length === 0) {
|
||||
csvContent += toCsvRow([
|
||||
a.id, a.title, new Date(a.date).toLocaleDateString(), a.type,
|
||||
a.subject.name, a.classGroup.name,
|
||||
`${a.teacher.firstName} ${a.teacher.lastName}`,
|
||||
a.maxScore ?? '', a.weight, '', '', '', '', '',
|
||||
]) + '\n';
|
||||
} else {
|
||||
for (const r of a.assessmentResults) {
|
||||
csvContent += toCsvRow([
|
||||
a.id, a.title, new Date(a.date).toLocaleDateString(), a.type,
|
||||
a.subject.name, a.classGroup.name,
|
||||
`${a.teacher.firstName} ${a.teacher.lastName}`,
|
||||
a.maxScore ?? '', a.weight,
|
||||
r.studentId, `${r.student.firstName} ${r.student.lastName}`,
|
||||
r.score ?? '', r.masteryLevelValue ?? '', r.note ?? '',
|
||||
]) + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filename = `assessments_export.csv`;
|
||||
} else if (type === 'grades') {
|
||||
// Grades CSV
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (schoolYearId) where.schoolYearId = schoolYearId;
|
||||
|
||||
let grades = await db.computedGrade.findMany({
|
||||
where,
|
||||
orderBy: [{ student: { lastName: 'asc' } }],
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (schoolId && !classGroupId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
grades = grades.filter((g) => classIds.includes(g.classGroupId));
|
||||
}
|
||||
|
||||
const header = ['Student ID', 'Student First Name', 'Student Last Name', 'Subject', 'Class', 'School Year', 'Period', 'Computed Value', 'Overridden Value', 'Override Reason', 'Finalized'];
|
||||
csvContent = header.map(escapeCsvField).join(',') + '\n';
|
||||
|
||||
for (const g of grades) {
|
||||
csvContent += toCsvRow([
|
||||
g.studentId, g.student.firstName, g.student.lastName,
|
||||
g.subject.name, g.classGroup.name, g.schoolYear.label,
|
||||
g.period, g.computedValue.toFixed(2),
|
||||
g.overriddenValue?.toFixed(2) ?? '', g.overrideReason ?? '',
|
||||
g.isFinalized ? 'Yes' : 'No',
|
||||
]) + '\n';
|
||||
}
|
||||
|
||||
filename = `grades_export.csv`;
|
||||
}
|
||||
|
||||
// Create audit log for export
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'EXPORT',
|
||||
entityType: 'DataExport',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify({ type, classGroupId, schoolYearId }),
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('CSV Export error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createExportSchema = z.object({
|
||||
scope: z.enum(['STUDENT', 'CLASS', 'SCHOOL']),
|
||||
scopeId: z.string().optional(),
|
||||
format: z.enum(['CSV', 'JSON', 'PDF']).default('JSON'),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status');
|
||||
const requestedByUserId = searchParams.get('requestedByUserId') ?? session.userId;
|
||||
|
||||
const where: Record<string, unknown> = { requestedByUserId };
|
||||
if (status) where.status = status;
|
||||
|
||||
const exports = await db.dataExportRequest.findMany({
|
||||
where,
|
||||
orderBy: { requestedAt: 'desc' },
|
||||
include: {
|
||||
requestedByUser: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
school: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(exports);
|
||||
} catch (error) {
|
||||
console.error('DataExport GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const exportRequest = await db.dataExportRequest.create({
|
||||
data: {
|
||||
requestedByUserId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? parsed.data.scopeId ?? null,
|
||||
scope: parsed.data.scope,
|
||||
scopeId: parsed.data.scopeId ?? null,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// Process the export request based on scope
|
||||
// For now, we create the request and return it
|
||||
// In a real app, this would be processed asynchronously
|
||||
|
||||
let exportData: Record<string, unknown> = {};
|
||||
|
||||
if (parsed.data.scope === 'STUDENT' && parsed.data.scopeId) {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: parsed.data.scopeId, deletedAt: null },
|
||||
include: {
|
||||
school: { select: { name: true } },
|
||||
enrollments: {
|
||||
include: {
|
||||
classGroup: { select: { name: true, gradeLevel: true } },
|
||||
},
|
||||
},
|
||||
learningProgressEntries: {
|
||||
include: {
|
||||
competency: { select: { code: true, title: true } },
|
||||
teacher: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
assessmentResults: {
|
||||
include: {
|
||||
assessment: { select: { title: true, type: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
exportData = { student };
|
||||
} else if (parsed.data.scope === 'CLASS' && parsed.data.scopeId) {
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: parsed.data.scopeId },
|
||||
include: {
|
||||
school: { select: { name: true } },
|
||||
schoolYear: { select: { label: true } },
|
||||
teachers: {
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assessments: {
|
||||
include: {
|
||||
subject: { select: { name: true } },
|
||||
_count: { select: { assessmentResults: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
exportData = { classGroup };
|
||||
} else if (parsed.data.scope === 'SCHOOL') {
|
||||
const schoolId = parsed.data.scopeId ?? session.user?.schoolId;
|
||||
if (schoolId) {
|
||||
const school = await db.school.findUnique({
|
||||
where: { id: schoolId },
|
||||
include: {
|
||||
classGroups: {
|
||||
include: {
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
},
|
||||
subjects: true,
|
||||
_count: {
|
||||
select: {
|
||||
users: { where: { deletedAt: null } },
|
||||
students: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
exportData = { school };
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to completed
|
||||
await db.dataExportRequest.update({
|
||||
where: { id: exportRequest.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...exportRequest,
|
||||
status: 'COMPLETED',
|
||||
exportData,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('DataExport POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// PUT /api/demo-accounts/[id] — Toggle a single demo user's active status
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { action } = body; // 'enable' or 'disable'
|
||||
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
if (!user || !user.isDemo) {
|
||||
return NextResponse.json({ error: 'Demo account not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'disable') {
|
||||
await db.user.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
} else if (action === 'enable') {
|
||||
await db.user.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid action. Use "enable" or "disable"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await db.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isDemo: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, account: updated });
|
||||
} catch (error) {
|
||||
console.error('Demo account PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/demo-accounts/[id] — Permanently delete a single demo user
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db.user.findUnique({ where: { id } });
|
||||
if (!user || !user.isDemo) {
|
||||
return NextResponse.json({ error: 'Demo account not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.user.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Demo account DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// GET /api/demo-accounts — List all demo users
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const demoUsers = await db.user.findMany({
|
||||
where: { isDemo: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isDemo: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(demoUsers);
|
||||
} catch (error) {
|
||||
console.error('Demo accounts GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/demo-accounts — Toggle all demo accounts (enable/disable)
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action } = body; // 'enable' or 'disable'
|
||||
|
||||
if (action === 'disable') {
|
||||
await db.user.updateMany({
|
||||
where: { isDemo: true, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
} else if (action === 'enable') {
|
||||
await db.user.updateMany({
|
||||
where: { isDemo: true, deletedAt: { not: null } },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid action. Use "enable" or "disable"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const demoUsers = await db.user.findMany({
|
||||
where: { isDemo: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isDemo: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, accounts: demoUsers });
|
||||
} catch (error) {
|
||||
console.error('Demo accounts PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/demo-accounts — Permanently delete all demo users
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await db.user.deleteMany({
|
||||
where: { isDemo: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, deleted: result.count });
|
||||
} catch (error) {
|
||||
console.error('Demo accounts DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const weightRuleSchema = z.object({
|
||||
sourceType: z.enum(['LEARNING_PROGRESS', 'ASSESSMENT']),
|
||||
targetRef: z.string().optional(),
|
||||
weightPercent: z.number().min(0).max(100).default(50.0),
|
||||
});
|
||||
|
||||
const createSchemeSchema = z.object({
|
||||
classGroupId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
schoolId: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
type: z.enum(['NUMERIC_GRADE', 'VERBAL_FEEDBACK', 'COMBINED']).default('NUMERIC_GRADE'),
|
||||
scaleDefinition: z.string(), // JSON string
|
||||
weightRules: z.array(weightRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const computeGrades = searchParams.get('computeGrades') === 'true';
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (subjectId) where.subjectId = subjectId;
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
|
||||
const schemes = await db.gradingScheme.findMany({
|
||||
where,
|
||||
include: {
|
||||
gradingWeightRules: { orderBy: { weightPercent: 'desc' } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
school: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (computeGrades && classGroupId && subjectId) {
|
||||
// Compute grades based on weight rules
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
if (!schoolYearId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'schoolYearId is required for grade computation' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get enrolled students
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
select: { studentId: true },
|
||||
});
|
||||
|
||||
const studentIds = enrollments.map((e) => e.studentId);
|
||||
const computedGrades: Array<{
|
||||
studentId: string;
|
||||
computedValue: number;
|
||||
breakdown: Array<{ source: string; weight: number; value: number }>;
|
||||
}> = [];
|
||||
|
||||
for (const scheme of schemes) {
|
||||
const rules = scheme.gradingWeightRules;
|
||||
if (rules.length === 0) continue;
|
||||
|
||||
const scale = JSON.parse(scheme.scaleDefinition);
|
||||
const maxLevel = scale.max ?? 4;
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
let totalWeightedValue = 0;
|
||||
let totalWeight = 0;
|
||||
const breakdown: Array<{ source: string; weight: number; value: number }> = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
let value = 0;
|
||||
if (rule.sourceType === 'LEARNING_PROGRESS') {
|
||||
// Average mastery level from progress entries
|
||||
const whereEntry: Record<string, unknown> = {
|
||||
studentId,
|
||||
classGroupId,
|
||||
};
|
||||
if (rule.targetRef) {
|
||||
// targetRef could be a categoryId or competencyId
|
||||
whereEntry.competency = {
|
||||
OR: [
|
||||
{ id: rule.targetRef },
|
||||
{ categoryId: rule.targetRef },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: whereEntry,
|
||||
orderBy: { date: 'desc' },
|
||||
select: { masteryLevelValue: true, competencyId: true },
|
||||
});
|
||||
|
||||
// Get latest per competency
|
||||
const latestMap = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
if (!latestMap.has(entry.competencyId)) {
|
||||
latestMap.set(entry.competencyId, entry.masteryLevelValue);
|
||||
}
|
||||
}
|
||||
|
||||
const avg =
|
||||
latestMap.size > 0
|
||||
? Array.from(latestMap.values()).reduce((a, b) => a + b, 0) /
|
||||
latestMap.size
|
||||
: 0;
|
||||
|
||||
value = avg / maxLevel; // Normalize to 0-1
|
||||
} else if (rule.sourceType === 'ASSESSMENT') {
|
||||
const whereResult: Record<string, unknown> = {
|
||||
studentId,
|
||||
};
|
||||
if (rule.targetRef) {
|
||||
whereResult.assessmentId = rule.targetRef;
|
||||
}
|
||||
|
||||
const results = await db.assessmentResult.findMany({
|
||||
where: whereResult,
|
||||
include: {
|
||||
assessment: { select: { maxScore: true, weight: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (results.length > 0) {
|
||||
const totalScore = results.reduce((sum, r) => {
|
||||
if (r.score !== null && r.assessment.maxScore) {
|
||||
return sum + (r.score / r.assessment.maxScore) * r.assessment.weight;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const totalWeight = results.reduce(
|
||||
(sum, r) => sum + (r.assessment.weight ?? 1),
|
||||
0
|
||||
);
|
||||
value = totalWeight > 0 ? totalScore / totalWeight : 0;
|
||||
}
|
||||
}
|
||||
|
||||
totalWeightedValue += value * (rule.weightPercent / 100);
|
||||
totalWeight += rule.weightPercent / 100;
|
||||
breakdown.push({
|
||||
source: rule.sourceType,
|
||||
weight: rule.weightPercent,
|
||||
value: Math.round(value * 1000) / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const computedValue =
|
||||
totalWeight > 0
|
||||
? Math.round((totalWeightedValue / totalWeight) * maxLevel * 100) / 100
|
||||
: 0;
|
||||
|
||||
computedGrades.push({
|
||||
studentId,
|
||||
computedValue,
|
||||
breakdown,
|
||||
});
|
||||
|
||||
// Store computed grade in DB
|
||||
await db.computedGrade.upsert({
|
||||
where: {
|
||||
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_${scheme.id}`,
|
||||
},
|
||||
update: {
|
||||
computedValue,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_${scheme.id}`,
|
||||
studentId,
|
||||
subjectId: subjectId!,
|
||||
classGroupId: classGroupId!,
|
||||
schoolYearId: schoolYearId!,
|
||||
period: 'Semester 1',
|
||||
computedValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ schemes, computedGrades });
|
||||
}
|
||||
|
||||
return NextResponse.json(schemes);
|
||||
} catch (error) {
|
||||
console.error('Grading GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSchemeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { weightRules, ...schemeData } = parsed.data;
|
||||
|
||||
const scheme = await db.gradingScheme.create({
|
||||
data: {
|
||||
classGroupId: schemeData.classGroupId ?? null,
|
||||
subjectId: schemeData.subjectId ?? null,
|
||||
schoolId: schemeData.schoolId ?? null,
|
||||
name: schemeData.name,
|
||||
type: schemeData.type,
|
||||
scaleDefinition: schemeData.scaleDefinition,
|
||||
gradingWeightRules: weightRules
|
||||
? {
|
||||
create: weightRules.map((rule) => ({
|
||||
sourceType: rule.sourceType,
|
||||
targetRef: rule.targetRef ?? null,
|
||||
weightPercent: rule.weightPercent,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
gradingWeightRules: true,
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(scheme, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Grading POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Grade override path
|
||||
if (body.studentId && body.overriddenValue !== undefined) {
|
||||
const overrideSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
subjectId: z.string().min(1),
|
||||
classGroupId: z.string().min(1),
|
||||
schoolYearId: z.string().min(1),
|
||||
overriddenValue: z.number(),
|
||||
overrideReason: z.string().min(1),
|
||||
});
|
||||
|
||||
const parsed = overrideSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { studentId, subjectId, classGroupId, schoolYearId, overriddenValue, overrideReason } = parsed.data;
|
||||
|
||||
// Find existing computed grade
|
||||
const existing = await db.computedGrade.findFirst({
|
||||
where: { studentId, subjectId, classGroupId, schoolYearId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.computedGrade.update({
|
||||
where: { id: existing.id },
|
||||
data: { overriddenValue, overrideReason, isFinalized: true },
|
||||
});
|
||||
} else {
|
||||
await db.computedGrade.create({
|
||||
data: {
|
||||
id: `${studentId}_${subjectId}_${classGroupId}_${schoolYearId}_override`,
|
||||
studentId,
|
||||
subjectId,
|
||||
classGroupId,
|
||||
schoolYearId,
|
||||
period: 'Semester 1',
|
||||
computedValue: overriddenValue,
|
||||
overriddenValue,
|
||||
overrideReason,
|
||||
isFinalized: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Scheme update path
|
||||
const updateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
type: z.enum(['NUMERIC_GRADE', 'VERBAL_FEEDBACK', 'COMBINED']).optional(),
|
||||
scaleDefinition: z.string().optional(),
|
||||
});
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, ...updateData } = parsed.data;
|
||||
|
||||
const scheme = await db.gradingScheme.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
gradingWeightRules: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(scheme);
|
||||
} catch (error) {
|
||||
console.error('Grading PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const batchDeleteSchema = z.object({
|
||||
ids: z.array(z.string()).min(1).max(200),
|
||||
});
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (session.user.role !== 'TEACHER' && session.user.role !== 'SCHOOL_ADMIN' && session.user.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = batchDeleteSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join(', ') }, { status: 400 });
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
|
||||
try {
|
||||
const result = await db.learningProgressEntry.deleteMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
teacherId: session.userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user.schoolId,
|
||||
action: 'BATCH_DELETE',
|
||||
entityType: 'LearningProgressEntry',
|
||||
metadata: JSON.stringify({ count: result.count, ids }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ deleted: result.count });
|
||||
} catch (error) {
|
||||
console.error('Batch delete error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete entries' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const entrySchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
competencyId: z.string().min(1),
|
||||
teacherId: z.string().min(1),
|
||||
classGroupId: z.string().min(1),
|
||||
date: z.string().transform((v) => new Date(v)),
|
||||
masteryLevelValue: z.number().int().min(1),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
|
||||
const bulkEntrySchema = z.array(entrySchema);
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const competencyId = searchParams.get('competencyId');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (studentId) where.studentId = studentId;
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (competencyId) where.competencyId = competencyId;
|
||||
|
||||
if (startDate || endDate) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (startDate) dateFilter.gte = new Date(startDate);
|
||||
if (endDate) dateFilter.lte = new Date(endDate);
|
||||
where.date = dateFilter;
|
||||
}
|
||||
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
category: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entries);
|
||||
} catch (error) {
|
||||
console.error('LearningProgress GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Support both single entry and bulk (array) entries
|
||||
const isBulk = Array.isArray(body);
|
||||
|
||||
if (isBulk) {
|
||||
const parsed = bulkEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entries = await db.learningProgressEntry.createMany({
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ created: entries.count },
|
||||
{ status: 201 }
|
||||
);
|
||||
} else {
|
||||
const parsed = entrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entry = await db.learningProgressEntry.create({
|
||||
data: parsed.data,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
competency: {
|
||||
select: { id: true, code: true, title: true },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entry, { status: 201 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LearningProgress POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const statusEnum = z.enum(['draft', 'scheduled', 'completed', 'cancelled']);
|
||||
|
||||
const updateLessonPlanSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
classGroupId: z.string().min(1).optional(),
|
||||
subjectId: z.string().optional().nullable(),
|
||||
date: z.string().min(1).optional(),
|
||||
durationMin: z.number().int().min(1).max(480).optional(),
|
||||
status: statusEnum.optional(),
|
||||
objectives: z.array(z.string()).optional().nullable(),
|
||||
materials: z.array(z.string()).optional().nullable(),
|
||||
homework: z.string().optional().nullable(),
|
||||
reflection: z.string().optional().nullable(),
|
||||
linkedCompetencyIds: z.array(z.string()).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const lessonPlan = await db.lessonPlan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true, schoolId: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!lessonPlan) {
|
||||
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = lessonPlan.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
lessonPlan.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(lessonPlan);
|
||||
} catch (error) {
|
||||
console.error('LessonPlan GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateLessonPlanSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.lessonPlan.findUnique({
|
||||
where: { id },
|
||||
include: { classGroup: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.title !== undefined) data.title = parsed.data.title;
|
||||
if (parsed.data.description !== undefined) data.description = parsed.data.description;
|
||||
if (parsed.data.classGroupId !== undefined) data.classGroupId = parsed.data.classGroupId;
|
||||
if (parsed.data.subjectId !== undefined) data.subjectId = parsed.data.subjectId || null;
|
||||
if (parsed.data.date !== undefined) data.date = new Date(parsed.data.date);
|
||||
if (parsed.data.durationMin !== undefined) data.durationMin = parsed.data.durationMin;
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.homework !== undefined) data.homework = parsed.data.homework;
|
||||
if (parsed.data.reflection !== undefined) data.reflection = parsed.data.reflection;
|
||||
if (parsed.data.objectives !== undefined) {
|
||||
data.objectives = parsed.data.objectives ? JSON.stringify(parsed.data.objectives) : null;
|
||||
}
|
||||
if (parsed.data.materials !== undefined) {
|
||||
data.materials = parsed.data.materials ? JSON.stringify(parsed.data.materials) : null;
|
||||
}
|
||||
if (parsed.data.linkedCompetencyIds !== undefined) {
|
||||
data.linkedCompetencyIds = parsed.data.linkedCompetencyIds
|
||||
? JSON.stringify(parsed.data.linkedCompetencyIds)
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await db.lessonPlan.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.classGroup.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'LessonPlan',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('LessonPlan PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.lessonPlan.findUnique({
|
||||
where: { id },
|
||||
include: { classGroup: { select: { schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Lesson plan not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.classGroup.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.lessonPlan.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.classGroup.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'LessonPlan',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ title: existing.title, date: existing.date.toISOString() }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('LessonPlan DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const statusEnum = z.enum(['draft', 'scheduled', 'completed', 'cancelled']);
|
||||
|
||||
const createLessonPlanSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
classGroupId: z.string().min(1),
|
||||
subjectId: z.string().optional().nullable(),
|
||||
date: z.string().min(1),
|
||||
durationMin: z.number().int().min(1).max(480).optional(),
|
||||
status: statusEnum.optional(),
|
||||
objectives: z.array(z.string()).optional(),
|
||||
materials: z.array(z.string()).optional(),
|
||||
homework: z.string().optional().nullable(),
|
||||
reflection: z.string().optional().nullable(),
|
||||
linkedCompetencyIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
// Find all classes that belong to the user's school
|
||||
const schoolId = session.user?.schoolId ?? undefined;
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: schoolId ? { schoolId } : {},
|
||||
select: { id: true },
|
||||
});
|
||||
const schoolClassIds = classGroups.map((c) => c.id);
|
||||
|
||||
if (schoolClassIds.length === 0) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId && classGroupId !== 'all') {
|
||||
where.classGroupId = classGroupId;
|
||||
} else {
|
||||
where.classGroupId = { in: schoolClassIds };
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
const dateFilter: Record<string, unknown> = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) dateFilter.lte = new Date(dateTo);
|
||||
where.date = dateFilter;
|
||||
}
|
||||
if (status && status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const lessonPlans = await db.lessonPlan.findMany({
|
||||
where,
|
||||
orderBy: { date: 'asc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(lessonPlans);
|
||||
} catch (error) {
|
||||
console.error('LessonPlans GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createLessonPlanSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { classGroupId, subjectId, date, objectives, materials, linkedCompetencyIds, ...rest } = parsed.data;
|
||||
|
||||
// Verify class exists and belongs to user's school
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!classGroup) {
|
||||
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
classGroup.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify subject (if provided) belongs to same school
|
||||
if (subjectId) {
|
||||
const subject = await db.subject.findUnique({
|
||||
where: { id: subjectId },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
if (!subject) {
|
||||
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
subject.schoolId &&
|
||||
subject.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const lessonPlan = await db.lessonPlan.create({
|
||||
data: {
|
||||
...rest,
|
||||
teacherId: session.userId,
|
||||
classGroupId,
|
||||
subjectId: subjectId || null,
|
||||
date: new Date(date),
|
||||
objectives: objectives ? JSON.stringify(objectives) : null,
|
||||
materials: materials ? JSON.stringify(materials) : null,
|
||||
linkedCompetencyIds: linkedCompetencyIds ? JSON.stringify(linkedCompetencyIds) : null,
|
||||
homework: parsed.data.homework ?? null,
|
||||
reflection: parsed.data.reflection ?? null,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: classGroup.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'LessonPlan',
|
||||
entityId: lessonPlan.id,
|
||||
metadata: JSON.stringify({
|
||||
title: parsed.data.title,
|
||||
classGroupId,
|
||||
date,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(lessonPlan, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('LessonPlans POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
|
||||
if (!classGroupId || !subjectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'classGroupId and subjectId are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the competency template assigned to this class + subject
|
||||
const assignment = await db.classCompetencyAssignment.findFirst({
|
||||
where: { classGroupId, subjectId },
|
||||
include: {
|
||||
competencyTemplate: {
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
categoryId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No competency template assigned for this class/subject' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
|
||||
|
||||
// Re-fetch with full template if cloned (so the categories / competencies match the cloned tree)
|
||||
let categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
order: number;
|
||||
competencies: Array<{ id: string; code: string; title: string; categoryId: string }>;
|
||||
}>;
|
||||
|
||||
if (assignment.clonedTemplateId) {
|
||||
const clonedTemplate = await db.competencyTemplate.findUnique({
|
||||
where: { id: assignment.clonedTemplateId },
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true, code: true, title: true, categoryId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
categories = clonedTemplate?.categories ?? assignment.competencyTemplate.categories;
|
||||
} else {
|
||||
categories = assignment.competencyTemplate.categories;
|
||||
}
|
||||
|
||||
// Flatten competencies with category info
|
||||
const competencies = categories.flatMap((cat) =>
|
||||
cat.competencies.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
category: {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
color: cat.color,
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
if (competencies.length === 0) {
|
||||
return NextResponse.json({ students: [], competencies: [], matrix: [] });
|
||||
}
|
||||
|
||||
// Get students enrolled in this class (active enrollments)
|
||||
const enrollments = await db.enrollment.findMany({
|
||||
where: { classGroupId, endDate: null },
|
||||
include: {
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true, deletedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const students = enrollments
|
||||
.filter((e) => e.student.deletedAt === null)
|
||||
.map((e) => ({
|
||||
id: e.student.id,
|
||||
firstName: e.student.firstName,
|
||||
lastName: e.student.lastName,
|
||||
}))
|
||||
.sort((a, b) => a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName));
|
||||
|
||||
if (students.length === 0) {
|
||||
return NextResponse.json({ students: [], competencies, matrix: [] });
|
||||
}
|
||||
|
||||
const studentIds = students.map((s) => s.id);
|
||||
const competencyIds = competencies.map((c) => c.id);
|
||||
|
||||
// Fetch all progress entries for these students + competencies in this class
|
||||
// (scope by competency.category.competencyTemplateId = templateId to avoid cross-template noise)
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
studentId: { in: studentIds },
|
||||
classGroupId,
|
||||
competencyId: { in: competencyIds },
|
||||
competency: {
|
||||
category: { competencyTemplateId: templateId },
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
studentId: true,
|
||||
competencyId: true,
|
||||
date: true,
|
||||
masteryLevelValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
// For each (student, competency) keep the latest entry (entries are sorted desc by date)
|
||||
const latestMap = new Map<string, { level: number; count: number; lastDate: string | null }>();
|
||||
for (const e of progressEntries) {
|
||||
const key = `${e.studentId}::${e.competencyId}`;
|
||||
const cur = latestMap.get(key);
|
||||
if (cur) {
|
||||
cur.count += 1;
|
||||
// Keep earliest "latest" date if already set, else update if newer
|
||||
if (!cur.lastDate || new Date(e.date).getTime() > new Date(cur.lastDate).getTime()) {
|
||||
cur.lastDate = e.date.toISOString();
|
||||
}
|
||||
} else {
|
||||
latestMap.set(key, {
|
||||
level: e.masteryLevelValue,
|
||||
count: 1,
|
||||
lastDate: e.date.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const matrix = Array.from(latestMap.entries()).map(([key, v]) => {
|
||||
const [studentId, competencyId] = key.split('::');
|
||||
return {
|
||||
studentId,
|
||||
competencyId,
|
||||
latestMasteryLevel: v.level,
|
||||
entryCount: v.count,
|
||||
lastEntryDate: v.lastDate,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ students, competencies, matrix });
|
||||
} catch (error) {
|
||||
console.error('MasteryMatrix GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// CompetenceTrack — Notifications API
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
// In-memory store for read notification IDs (per server instance)
|
||||
const readNotificationIds = new Set<string>();
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const schoolId = session.user.schoolId;
|
||||
const role = session.user.role;
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: [],
|
||||
missingObservations: [],
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Get teacher's class IDs
|
||||
const teacherClasses = await db.classGroupTeacher.findMany({
|
||||
where: { userId },
|
||||
select: { classGroupId: true },
|
||||
});
|
||||
const classGroupIds = teacherClasses.map((tc) => tc.classGroupId);
|
||||
|
||||
// For admin, get all classes in the school
|
||||
let targetClassIds = classGroupIds;
|
||||
if (role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN') {
|
||||
const schoolClasses = await db.classGroup.findMany({
|
||||
where: { schoolId },
|
||||
select: { id: true },
|
||||
});
|
||||
targetClassIds = schoolClasses.map((c) => c.id);
|
||||
}
|
||||
|
||||
if (targetClassIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: [],
|
||||
missingObservations: [],
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Upcoming assessments (next 7 days, not completed)
|
||||
const upcomingAssessments = await db.assessment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
date: {
|
||||
gte: now,
|
||||
lte: sevenDaysFromNow,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
classGroup: { select: { name: true } },
|
||||
subject: { select: { name: true } },
|
||||
assessmentResults: { select: { id: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const assessmentNotifications = upcomingAssessments
|
||||
.filter((a) => a.assessmentResults.length === 0)
|
||||
.map((a) => {
|
||||
const diffMs = new Date(a.date).getTime() - now.getTime();
|
||||
const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)));
|
||||
const id = `assessment-${a.id}`;
|
||||
return {
|
||||
id,
|
||||
type: 'assessment' as const,
|
||||
title: a.title,
|
||||
description: `${a.classGroup.name} · ${a.subject.name}`,
|
||||
daysUntil,
|
||||
timestamp: t('notifications.in_days', { days: String(daysUntil) }),
|
||||
classGroupId: a.classGroupId,
|
||||
subjectId: a.subjectId,
|
||||
assessmentId: a.id,
|
||||
isRead: readNotificationIds.has(id),
|
||||
};
|
||||
});
|
||||
|
||||
// Students with no progress entries in last 14 days
|
||||
const activeEnrollments = await db.enrollment.findMany({
|
||||
where: {
|
||||
classGroupId: { in: targetClassIds },
|
||||
endDate: null,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
learningProgressEntries: {
|
||||
where: {
|
||||
date: { gte: fourteenDaysAgo },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const missingObservations = activeEnrollments
|
||||
.filter((e) => e.student.learningProgressEntries.length === 0)
|
||||
.map((e) => {
|
||||
const id = `missing-${e.student.id}-${e.classGroupId}`;
|
||||
// Calculate days since last progress (use 14 as default since we filtered for 14 days)
|
||||
return {
|
||||
id,
|
||||
type: 'missing_observation' as const,
|
||||
title: `${e.student.firstName} ${e.student.lastName}`,
|
||||
description: e.classGroup.name,
|
||||
daysSince: 14,
|
||||
timestamp: t('notifications.days_ago', { days: '14' }),
|
||||
studentId: e.student.id,
|
||||
classGroupId: e.classGroupId,
|
||||
isRead: readNotificationIds.has(id),
|
||||
};
|
||||
});
|
||||
|
||||
// Limit to 10 most relevant
|
||||
const limitedMissing = missingObservations.slice(0, 10);
|
||||
|
||||
const allItems = [...assessmentNotifications, ...limitedMissing];
|
||||
const unreadCount = allItems.filter((n) => !n.isRead).length;
|
||||
|
||||
return NextResponse.json({
|
||||
upcomingAssessments: assessmentNotifications,
|
||||
missingObservations: limitedMissing,
|
||||
unreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { ids } = body as { ids?: string[] };
|
||||
|
||||
if (ids && Array.isArray(ids)) {
|
||||
for (const id of ids) {
|
||||
readNotificationIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper — minimal i18n for server-side timestamps
|
||||
function t(key: string, params?: Record<string, string>): string {
|
||||
const dict: Record<string, string> = {
|
||||
'notifications.in_days': 'in {days} Tagen',
|
||||
'notifications.days_ago': 'vor {days} Tagen',
|
||||
};
|
||||
let text = dict[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const categoryEnum = z.enum([
|
||||
'general',
|
||||
'progress',
|
||||
'assessment',
|
||||
'behavior',
|
||||
'attendance',
|
||||
'event',
|
||||
]);
|
||||
|
||||
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const statusEnum = z.enum(['draft', 'sent', 'delivered', 'read', 'replied']);
|
||||
|
||||
const updateParentMessageSchema = z.object({
|
||||
subject: z.string().min(1).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
category: categoryEnum.optional(),
|
||||
priority: priorityEnum.optional(),
|
||||
status: statusEnum.optional(),
|
||||
readAt: z.string().datetime().optional().nullable(),
|
||||
reply: z.string().optional().nullable(),
|
||||
replyAt: z.string().datetime().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const message = await db.parentMessage.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
relationship: true,
|
||||
preferredContact: true,
|
||||
preferredLanguage: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// School-scoped access check
|
||||
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: message.studentId },
|
||||
select: { schoolId: true },
|
||||
});
|
||||
if (student && student.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(message);
|
||||
} catch (error) {
|
||||
console.error('ParentMessage GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateParentMessageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.parentMessage.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { id: true, schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.subject !== undefined) data.subject = parsed.data.subject;
|
||||
if (parsed.data.body !== undefined) data.body = parsed.data.body;
|
||||
if (parsed.data.category !== undefined) data.category = parsed.data.category;
|
||||
if (parsed.data.priority !== undefined) data.priority = parsed.data.priority;
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.readAt !== undefined) {
|
||||
data.readAt = parsed.data.readAt ? new Date(parsed.data.readAt) : null;
|
||||
}
|
||||
if (parsed.data.reply !== undefined) {
|
||||
data.reply = parsed.data.reply || null;
|
||||
}
|
||||
if (parsed.data.replyAt !== undefined) {
|
||||
data.replyAt = parsed.data.replyAt ? new Date(parsed.data.replyAt) : null;
|
||||
}
|
||||
// If reply is set and status wasn't provided, mark as replied
|
||||
if (parsed.data.reply !== undefined && parsed.data.reply && parsed.data.status === undefined) {
|
||||
data.status = 'replied';
|
||||
if (!existing.replyAt && parsed.data.replyAt === undefined) {
|
||||
data.replyAt = new Date();
|
||||
}
|
||||
}
|
||||
// If marking as read and readAt not set
|
||||
if (parsed.data.status === 'read' && !existing.readAt && parsed.data.readAt === undefined) {
|
||||
data.readAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await db.parentMessage.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
relationship: true,
|
||||
preferredContact: true,
|
||||
preferredLanguage: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'ParentMessage',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('ParentMessage PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.parentMessage.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { id: true, schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Message not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isTeacher = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isTeacher && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.parentMessage.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'ParentMessage',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
subject: existing.subject,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('ParentMessage DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const categoryEnum = z.enum([
|
||||
'general',
|
||||
'progress',
|
||||
'assessment',
|
||||
'behavior',
|
||||
'attendance',
|
||||
'event',
|
||||
]);
|
||||
|
||||
const priorityEnum = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const statusEnum = z.enum(['draft', 'sent', 'delivered', 'read', 'replied']);
|
||||
|
||||
const createParentMessageSchema = z.object({
|
||||
parentId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
category: categoryEnum.optional(),
|
||||
priority: priorityEnum.optional(),
|
||||
status: statusEnum.optional(),
|
||||
});
|
||||
|
||||
const listWhereSchema = z.object({
|
||||
teacherId: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
studentId: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawParams = {
|
||||
teacherId: searchParams.get('teacherId') ?? undefined,
|
||||
parentId: searchParams.get('parentId') ?? undefined,
|
||||
studentId: searchParams.get('studentId') ?? undefined,
|
||||
category: searchParams.get('category') ?? undefined,
|
||||
status: searchParams.get('status') ?? undefined,
|
||||
};
|
||||
|
||||
const parsed = listWhereSchema.safeParse(rawParams);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cursor = searchParams.get('cursor');
|
||||
const limitParam = searchParams.get('limit');
|
||||
const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
|
||||
|
||||
// Restrict to messages whose student belongs to user's school if SCHOOL_ADMIN
|
||||
const where: Record<string, unknown> = {};
|
||||
if (parsed.data.teacherId) where.teacherId = parsed.data.teacherId;
|
||||
if (parsed.data.parentId) where.parentId = parsed.data.parentId;
|
||||
if (parsed.data.studentId) where.studentId = parsed.data.studentId;
|
||||
if (parsed.data.category && parsed.data.category !== 'all') where.category = parsed.data.category;
|
||||
if (parsed.data.status && parsed.data.status !== 'all') where.status = parsed.data.status;
|
||||
|
||||
if (session.user?.schoolId && session.user.role !== 'SUPER_ADMIN') {
|
||||
where.student = { schoolId: session.user.schoolId };
|
||||
}
|
||||
|
||||
const messages = await db.parentMessage.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
relationship: true,
|
||||
preferredContact: true,
|
||||
preferredLanguage: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = messages.length > limit;
|
||||
const items = hasMore ? messages.slice(0, limit) : messages;
|
||||
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].id : null;
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ParentMessages GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createParentMessageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { parentId, studentId, ...rest } = parsed.data;
|
||||
|
||||
// Verify parent contact exists and matches student
|
||||
const parent = await db.parentContact.findUnique({
|
||||
where: { id: parentId },
|
||||
include: { student: { select: { id: true, schoolId: true, deletedAt: true } } },
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parent.studentId !== studentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Parent contact does not belong to this student' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
parent.student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const message = await db.parentMessage.create({
|
||||
data: {
|
||||
parentId,
|
||||
teacherId: session.userId,
|
||||
studentId,
|
||||
subject: rest.subject,
|
||||
body: rest.body,
|
||||
category: rest.category ?? 'general',
|
||||
priority: rest.priority ?? 'normal',
|
||||
status: rest.status ?? 'sent',
|
||||
},
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
relationship: true,
|
||||
preferredContact: true,
|
||||
preferredLanguage: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: parent.student.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'ParentMessage',
|
||||
entityId: message.id,
|
||||
metadata: JSON.stringify({
|
||||
parentId,
|
||||
studentId,
|
||||
subject: rest.subject,
|
||||
category: rest.category ?? 'general',
|
||||
priority: rest.priority ?? 'normal',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(message, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ParentMessages POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const relationshipEnum = z.enum(['parent', 'guardian', 'emergency']);
|
||||
const preferredContactEnum = z.enum(['email', 'phone', 'both']);
|
||||
|
||||
const updateParentContactSchema = z.object({
|
||||
firstName: z.string().min(1).optional(),
|
||||
lastName: z.string().min(1).optional(),
|
||||
email: z.string().email().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
relationship: relationshipEnum.optional(),
|
||||
preferredContact: preferredContactEnum.optional(),
|
||||
preferredLanguage: z.string().optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateParentContactSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.parentContact.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { id: true, schoolId: true, deletedAt: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
// Teachers can edit any parent contact of their school
|
||||
const isTeacherInSchool =
|
||||
session.user?.role === 'TEACHER' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
|
||||
if (!isSchoolAdmin && !isSuperAdmin && !isTeacherInSchool) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check uniqueness when email changes
|
||||
if (parsed.data.email !== undefined && parsed.data.email) {
|
||||
const dup = await db.parentContact.findUnique({
|
||||
where: {
|
||||
studentId_email: {
|
||||
studentId: existing.studentId,
|
||||
email: parsed.data.email,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (dup && dup.id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A contact with this email already exists for this student' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.firstName !== undefined) data.firstName = parsed.data.firstName;
|
||||
if (parsed.data.lastName !== undefined) data.lastName = parsed.data.lastName;
|
||||
if (parsed.data.email !== undefined) data.email = parsed.data.email || null;
|
||||
if (parsed.data.phone !== undefined) data.phone = parsed.data.phone || null;
|
||||
if (parsed.data.relationship !== undefined) data.relationship = parsed.data.relationship;
|
||||
if (parsed.data.preferredContact !== undefined) data.preferredContact = parsed.data.preferredContact;
|
||||
if (parsed.data.preferredLanguage !== undefined) data.preferredLanguage = parsed.data.preferredLanguage;
|
||||
if (parsed.data.notes !== undefined) data.notes = parsed.data.notes || null;
|
||||
|
||||
const updated = await db.parentContact.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'ParentContact',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ updatedFields: Object.keys(data) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('ParentContact PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.parentContact.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { id: true, schoolId: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Parent contact not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
const isTeacherInSchool =
|
||||
session.user?.role === 'TEACHER' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
|
||||
if (!isSchoolAdmin && !isSuperAdmin && !isTeacherInSchool) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Cascade delete messages
|
||||
await db.parentMessage.deleteMany({ where: { parentId: id } });
|
||||
await db.parentContact.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'ParentContact',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
firstName: existing.firstName,
|
||||
lastName: existing.lastName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('ParentContact DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const relationshipEnum = z.enum(['parent', 'guardian', 'emergency']);
|
||||
const preferredContactEnum = z.enum(['email', 'phone', 'both']);
|
||||
|
||||
const createParentContactSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
relationship: relationshipEnum.optional(),
|
||||
preferredContact: preferredContactEnum.optional(),
|
||||
preferredLanguage: z.string().optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const search = searchParams.get('search');
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (studentId) {
|
||||
where.studentId = studentId;
|
||||
} else if (session.user?.schoolId) {
|
||||
// Restrict to students in this school
|
||||
where.student = { schoolId: session.user.schoolId };
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
where.OR = [
|
||||
{ firstName: { contains: s } },
|
||||
{ lastName: { contains: s } },
|
||||
{ email: { contains: s } },
|
||||
{ student: { firstName: { contains: s } } },
|
||||
{ student: { lastName: { contains: s } } },
|
||||
];
|
||||
}
|
||||
|
||||
const contacts = await db.parentContact.findMany({
|
||||
where,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error('ParentContacts GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createParentContactSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { studentId, ...rest } = parsed.data;
|
||||
|
||||
// Verify student exists and belongs to user's school
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check uniqueness for (studentId, email) if email provided
|
||||
if (rest.email) {
|
||||
const existing = await db.parentContact.findUnique({
|
||||
where: { studentId_email: { studentId, email: rest.email } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A contact with this email already exists for this student' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contact = await db.parentContact.create({
|
||||
data: {
|
||||
studentId,
|
||||
firstName: rest.firstName,
|
||||
lastName: rest.lastName,
|
||||
email: rest.email ?? null,
|
||||
phone: rest.phone ?? null,
|
||||
relationship: rest.relationship ?? 'parent',
|
||||
preferredContact: rest.preferredContact ?? 'email',
|
||||
preferredLanguage: rest.preferredLanguage ?? 'de',
|
||||
notes: rest.notes ?? null,
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
select: {
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: student.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'ParentContact',
|
||||
entityId: contact.id,
|
||||
metadata: JSON.stringify({
|
||||
studentId,
|
||||
firstName: rest.firstName,
|
||||
lastName: rest.lastName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(contact, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ParentContacts POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const reportId = searchParams.get('reportId');
|
||||
|
||||
if (!reportId) {
|
||||
return NextResponse.json({ error: 'reportId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const report = await db.report.findUnique({
|
||||
where: { id: reportId },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencyCategory: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Generate a print-friendly HTML page
|
||||
const htmlContent = generatePrintHtml(report);
|
||||
|
||||
return new NextResponse(htmlContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Report PDF error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
const student = report.student as { firstName: string; lastName: string };
|
||||
const classGroup = report.classGroup as { name: string; gradeLevel: number };
|
||||
const schoolYear = report.schoolYear as { label: string };
|
||||
const generatedByUser = report.generatedByUser as { firstName: string; lastName: string };
|
||||
const sections = (report.sections as Array<{
|
||||
order: number;
|
||||
generatedText: string;
|
||||
competencyCategory: { name: string; color: string | null } | null;
|
||||
}>).sort((a, b) => a.order - b.order);
|
||||
|
||||
const sectionsHtml = sections.map((s) => {
|
||||
const catName = s.competencyCategory?.name ?? '';
|
||||
const catColor = s.competencyCategory?.color ?? '#10b981';
|
||||
return `
|
||||
<div class="section">
|
||||
${catName ? `<h3 class="section-title" style="color: ${catColor}">${catName}</h3>` : ''}
|
||||
<p class="section-text">${s.generatedText}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kompetenzbericht — ${student.firstName} ${student.lastName}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #10b981;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #10b981;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.header h2 {
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.meta dt {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-text {
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-draft {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-final {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
@media print {
|
||||
body { margin: 0; padding: 20px; }
|
||||
.no-print { display: none; }
|
||||
.section { break-inside: avoid; }
|
||||
}
|
||||
.print-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.print-btn:hover { background: #059669; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="print-btn no-print" onclick="window.print()">Drucken / Print</button>
|
||||
|
||||
<div class="header">
|
||||
<h1>Kompetenzbericht</h1>
|
||||
<h2>${student.firstName} ${student.lastName}</h2>
|
||||
<span class="badge ${String(report.status) === 'FINAL' ? 'badge-final' : 'badge-draft'}">${String(report.status)}</span>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div><dt>Klasse:</dt> <dd>${classGroup.name} (Jahrgang ${classGroup.gradeLevel})</dd></div>
|
||||
<div><dt>Schuljahr:</dt> <dd>${schoolYear.label}</dd></div>
|
||||
<div><dt>Zeitraum:</dt> <dd>${String(report.period)}</dd></div>
|
||||
<div><dt>Erstellt von:</dt> <dd>${generatedByUser.firstName} ${generatedByUser.lastName}</dd></div>
|
||||
<div><dt>Datum:</dt> <dd>${new Date(String(report.generatedAt)).toLocaleDateString()}</dd></div>
|
||||
<div><dt>Noten inklusive:</dt> <dd>${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}</dd></div>
|
||||
</div>
|
||||
|
||||
${sectionsHtml}
|
||||
|
||||
<div class="footer">
|
||||
<p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createReportSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
classGroupId: z.string().min(1),
|
||||
schoolYearId: z.string().min(1),
|
||||
period: z.string().min(1),
|
||||
includesGrades: z.boolean().default(false),
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
competencyCategoryId: z.string().optional(),
|
||||
generatedText: z.string().min(1),
|
||||
order: z.number().int().default(0),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const schoolYearId = searchParams.get('schoolYearId');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (studentId) where.studentId = studentId;
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
if (schoolYearId) where.schoolYearId = schoolYearId;
|
||||
if (status) where.status = status;
|
||||
|
||||
const reports = await db.report.findMany({
|
||||
where,
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
generatedByUser: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencyCategory: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(reports);
|
||||
} catch (error) {
|
||||
console.error('Reports GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { sections, ...reportData } = parsed.data;
|
||||
|
||||
const report = await db.report.create({
|
||||
data: {
|
||||
studentId: reportData.studentId,
|
||||
classGroupId: reportData.classGroupId,
|
||||
schoolYearId: reportData.schoolYearId,
|
||||
period: reportData.period,
|
||||
generatedByUserId: session.userId,
|
||||
includesGrades: reportData.includesGrades,
|
||||
status: 'DRAFT',
|
||||
sections: sections
|
||||
? {
|
||||
create: sections.map((s) => ({
|
||||
competencyCategoryId: s.competencyCategoryId ?? null,
|
||||
generatedText: s.generatedText,
|
||||
order: s.order,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
generatedByUser: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencyCategory: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(report, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Reports POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const updateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['DRAFT', 'FINAL']).optional(),
|
||||
includesGrades: z.boolean().optional(),
|
||||
pdfFilePath: z.string().optional(),
|
||||
sections: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
generatedText: z.string().min(1),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, sections, ...updateData } = parsed.data;
|
||||
|
||||
// Update sections if provided
|
||||
if (sections && sections.length > 0) {
|
||||
for (const section of sections) {
|
||||
await db.reportSection.update({
|
||||
where: { id: section.id },
|
||||
data: { generatedText: section.generatedText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const report = await db.report.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true, gradeLevel: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
generatedByUser: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
competencyCategory: {
|
||||
select: { id: true, name: true, color: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(report);
|
||||
} catch (error) {
|
||||
console.error('Reports PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ message: "Hello, world!" });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.rubric.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Create a duplicate with incremented version
|
||||
const { criteria, ...rubricData } = existing;
|
||||
const duplicated = await db.rubric.create({
|
||||
data: {
|
||||
schoolId: rubricData.schoolId,
|
||||
teacherId: session.userId,
|
||||
subjectId: rubricData.subjectId,
|
||||
title: `${rubricData.title} (Kopie)`,
|
||||
description: rubricData.description,
|
||||
type: rubricData.type,
|
||||
maxPoints: rubricData.maxPoints,
|
||||
isPublic: false, // Duplicates start as private
|
||||
version: rubricData.version + 1,
|
||||
criteria: {
|
||||
create: criteria.map((c, ci) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
weight: c.weight,
|
||||
maxPoints: c.maxPoints,
|
||||
order: c.order ?? ci,
|
||||
levels: {
|
||||
create: c.levels.map((l, li) => ({
|
||||
label: l.label,
|
||||
description: l.description,
|
||||
points: l.points,
|
||||
order: l.order ?? li,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'Rubric',
|
||||
entityId: duplicated.id,
|
||||
metadata: JSON.stringify({
|
||||
title: duplicated.title,
|
||||
type: 'DUPLICATE',
|
||||
sourceRubricId: id,
|
||||
sourceTitle: existing.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(duplicated, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Rubric Duplicate POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
const levelSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
label: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(2000),
|
||||
points: z.int().min(0),
|
||||
order: z.int().min(0).default(0),
|
||||
});
|
||||
|
||||
const criterionSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional().nullable(),
|
||||
weight: z.number().min(0).default(1.0),
|
||||
maxPoints: z.int().min(1),
|
||||
order: z.int().min(0).default(0),
|
||||
levels: z.array(levelSchema).min(1),
|
||||
});
|
||||
|
||||
const updateRubricSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
type: z.enum(['ANALYTIC', 'HOLISTIC']).optional(),
|
||||
subjectId: z.string().optional().nullable(),
|
||||
maxPoints: z.int().min(1).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
criteria: z.array(criterionSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const rubric = await db.rubric.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!rubric) {
|
||||
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
rubric.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(rubric);
|
||||
} catch (error) {
|
||||
console.error('Rubric GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateRubricSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.rubric.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Only the owner or admin can update
|
||||
if (existing.teacherId !== session.userId && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Only the owner can edit this rubric' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { criteria, subjectId, ...rest } = parsed.data;
|
||||
|
||||
// Update scalar fields
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (rest.title !== undefined) updateData.title = rest.title;
|
||||
if (rest.description !== undefined) updateData.description = rest.description;
|
||||
if (rest.type !== undefined) updateData.type = rest.type;
|
||||
if (rest.maxPoints !== undefined) updateData.maxPoints = rest.maxPoints;
|
||||
if (rest.isPublic !== undefined) updateData.isPublic = rest.isPublic;
|
||||
if (subjectId !== undefined) updateData.subjectId = subjectId ?? null;
|
||||
|
||||
// Handle nested criteria update
|
||||
if (criteria) {
|
||||
// Delete existing criteria (cascade will delete levels)
|
||||
await db.rubricCriterion.deleteMany({ where: { rubricId: id } });
|
||||
|
||||
// Create new criteria with levels
|
||||
updateData.criteria = {
|
||||
create: criteria.map((c, ci) => ({
|
||||
name: c.name,
|
||||
description: c.description ?? null,
|
||||
weight: c.weight,
|
||||
maxPoints: c.maxPoints,
|
||||
order: c.order ?? ci,
|
||||
levels: {
|
||||
create: c.levels.map((l, li) => ({
|
||||
label: l.label,
|
||||
description: l.description,
|
||||
points: l.points,
|
||||
order: l.order ?? li,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await db.rubric.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Rubric',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ title: updated.title, updatedFields: Object.keys(rest) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Rubric PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await db.rubric.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Rubric not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Only the owner or admin can delete
|
||||
if (existing.teacherId !== session.userId && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Only the owner can delete this rubric' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Cascade will delete criteria and levels
|
||||
await db.rubric.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'Rubric',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({ title: existing.title, type: existing.type }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Rubric DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
function isTeacherOrAdmin(role: string | undefined): boolean {
|
||||
return (
|
||||
role === 'TEACHER' ||
|
||||
role === 'SCHOOL_ADMIN' ||
|
||||
role === 'SUPER_ADMIN'
|
||||
);
|
||||
}
|
||||
|
||||
const levelSchema = z.object({
|
||||
id: z.string().optional(), // for updates
|
||||
label: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(2000),
|
||||
points: z.int().min(0),
|
||||
order: z.int().min(0).default(0),
|
||||
});
|
||||
|
||||
const criterionSchema = z.object({
|
||||
id: z.string().optional(), // for updates
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional().nullable(),
|
||||
weight: z.number().min(0).default(1.0),
|
||||
maxPoints: z.int().min(1),
|
||||
order: z.int().min(0).default(0),
|
||||
levels: z.array(levelSchema).min(1),
|
||||
});
|
||||
|
||||
const createRubricSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
type: z.enum(['ANALYTIC', 'HOLISTIC']).default('ANALYTIC'),
|
||||
subjectId: z.string().optional().nullable(),
|
||||
maxPoints: z.int().min(1).default(100),
|
||||
isPublic: z.boolean().default(false),
|
||||
criteria: z.array(criterionSchema).min(1),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
const teacherId = searchParams.get('teacherId');
|
||||
const subjectId = searchParams.get('subjectId');
|
||||
|
||||
let schoolId: string | undefined;
|
||||
if (session.user?.role === 'SCHOOL_ADMIN') {
|
||||
schoolId = session.user.schoolId ?? undefined;
|
||||
} else {
|
||||
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ schoolId, teacherId: session.userId },
|
||||
{ schoolId, isPublic: true },
|
||||
],
|
||||
};
|
||||
|
||||
if (teacherId) {
|
||||
where.teacherId = teacherId;
|
||||
}
|
||||
if (subjectId && subjectId !== 'all') {
|
||||
where.subjectId = subjectId;
|
||||
}
|
||||
|
||||
// If teacherId filter is set, override the OR to a simple AND
|
||||
if (teacherId) {
|
||||
const simpleWhere: Record<string, unknown> = { schoolId, teacherId };
|
||||
if (subjectId && subjectId !== 'all') simpleWhere.subjectId = subjectId;
|
||||
|
||||
const rubrics = await db.rubric.findMany({
|
||||
where: simpleWhere,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(rubrics);
|
||||
}
|
||||
|
||||
const rubrics = await db.rubric.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(rubrics);
|
||||
} catch (error) {
|
||||
console.error('Rubrics GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createRubricSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, criteria, subjectId, ...rest } = parsed.data;
|
||||
|
||||
// Verify school exists
|
||||
const school = await db.school.findUnique({ where: { id: schoolId } });
|
||||
if (!school) {
|
||||
return NextResponse.json({ error: 'School not found' }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
school.id !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify subject if provided
|
||||
if (subjectId) {
|
||||
const subject = await db.subject.findUnique({ where: { id: subjectId } });
|
||||
if (!subject) {
|
||||
return NextResponse.json({ error: 'Subject not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const rubric = await db.rubric.create({
|
||||
data: {
|
||||
schoolId,
|
||||
teacherId: session.userId,
|
||||
subjectId: subjectId ?? null,
|
||||
...rest,
|
||||
criteria: {
|
||||
create: criteria.map((c, ci) => ({
|
||||
name: c.name,
|
||||
description: c.description ?? null,
|
||||
weight: c.weight,
|
||||
maxPoints: c.maxPoints,
|
||||
order: c.order ?? ci,
|
||||
levels: {
|
||||
create: c.levels.map((l, li) => ({
|
||||
label: l.label,
|
||||
description: l.description,
|
||||
points: l.points,
|
||||
order: l.order ?? li,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
criteria: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { levels: { orderBy: { order: 'asc' } } },
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'Rubric',
|
||||
entityId: rubric.id,
|
||||
metadata: JSON.stringify({ title: rubric.title, type: rubric.type, criteriaCount: criteria.length }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(rubric, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Rubrics POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createSchoolYearSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
startDate: z.string().transform((v) => new Date(v)),
|
||||
endDate: z.string().transform((v) => new Date(v)),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'schoolId query parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const schoolYears = await db.schoolYear.findMany({
|
||||
where: { schoolId },
|
||||
orderBy: { startDate: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { classGroups: true, enrollments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(schoolYears);
|
||||
} catch (error) {
|
||||
console.error('SchoolYears GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSchoolYearSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const schoolYear = await db.schoolYear.create({
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
return NextResponse.json(schoolYear, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('SchoolYears POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createSchoolSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']),
|
||||
country: z.string().default('DE'),
|
||||
timezone: z.string().default('Europe/Berlin'),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolType = searchParams.get('schoolType');
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (schoolType) where.schoolType = schoolType;
|
||||
|
||||
// If user is not super admin, only show their school
|
||||
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
|
||||
where.id = session.user.schoolId;
|
||||
}
|
||||
|
||||
const schools = await db.school.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
users: { where: { deletedAt: null } },
|
||||
classGroups: true,
|
||||
students: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(schools);
|
||||
} catch (error) {
|
||||
console.error('Schools GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSchoolSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const school = await db.school.create({
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
return NextResponse.json(school, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Schools POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSchoolSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
schoolType: z.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER']).optional(),
|
||||
country: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateSchoolSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, ...updateData } = parsed.data;
|
||||
|
||||
// Verify user has access to this school
|
||||
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const school = await db.school.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
users: { where: { deletedAt: null } },
|
||||
classGroups: true,
|
||||
students: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'School',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify(updateData),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(school);
|
||||
} catch (error) {
|
||||
console.error('Schools PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: studentId } = await params;
|
||||
|
||||
// Load the student with relations
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
externalId: true,
|
||||
createdAt: true,
|
||||
schoolId: true,
|
||||
school: { select: { id: true, name: true } },
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
include: {
|
||||
classGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gradeLevel: true,
|
||||
schoolType: true,
|
||||
},
|
||||
},
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
orderBy: { startDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 1. Progress entries (timeline)
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 100,
|
||||
include: {
|
||||
competency: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
category: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Assessment results
|
||||
const assessmentResults = await db.assessmentResult.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { assessment: { date: 'desc' } },
|
||||
take: 50,
|
||||
include: {
|
||||
assessment: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
date: true,
|
||||
type: true,
|
||||
maxScore: true,
|
||||
subject: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Computed grades
|
||||
const computedGrades = await db.computedGrade.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { computedAt: 'desc' },
|
||||
take: 50,
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Reports
|
||||
const reports = await db.report.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { generatedAt: 'desc' },
|
||||
take: 30,
|
||||
include: {
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
generatedByUser: { select: { id: true, firstName: true, lastName: true } },
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true, generatedText: true, order: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Competence flowers — for each class+subject assignment, compute categories
|
||||
const classIds = student.enrollments.map((e) => e.classGroup.id);
|
||||
const assignments = classIds.length > 0
|
||||
? await db.classCompetencyAssignment.findMany({
|
||||
where: { classGroupId: { in: classIds } },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
competencyTemplate: {
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
competencies: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const flowers: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
categories: Array<{
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
color: string | null;
|
||||
averageMasteryLevel: number;
|
||||
competencyCount: number;
|
||||
assessedCompetencyCount: number;
|
||||
}>;
|
||||
}> = [];
|
||||
|
||||
const studentName = `${student.firstName} ${student.lastName}`;
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const template = assignment.competencyTemplate;
|
||||
const templateId = assignment.clonedTemplateId ?? assignment.competencyTemplateId;
|
||||
let categories = template.categories;
|
||||
|
||||
// If cloned, load the cloned template's categories
|
||||
if (assignment.clonedTemplateId) {
|
||||
const cloned = await db.competencyTemplate.findUnique({
|
||||
where: { id: assignment.clonedTemplateId },
|
||||
include: {
|
||||
categories: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
competencies: { orderBy: { order: 'asc' }, select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (cloned) categories = cloned.categories;
|
||||
}
|
||||
|
||||
// Get latest progress entries per competency for this student/class
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
studentId,
|
||||
classGroupId: assignment.classGroupId,
|
||||
competency: {
|
||||
category: { competencyTemplateId: templateId },
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
competencyId: true,
|
||||
masteryLevelValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Latest value per competency (entries sorted by date desc)
|
||||
const compMap = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
if (!compMap.has(entry.competencyId)) {
|
||||
compMap.set(entry.competencyId, entry.masteryLevelValue);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryResults = categories.map((cat) => {
|
||||
const catCompetencyIds = cat.competencies.map((c) => c.id);
|
||||
const assessedValues = catCompetencyIds
|
||||
.filter((cid) => compMap.has(cid))
|
||||
.map((cid) => compMap.get(cid)!);
|
||||
const avg =
|
||||
assessedValues.length > 0
|
||||
? assessedValues.reduce((a, b) => a + b, 0) / assessedValues.length
|
||||
: 0;
|
||||
return {
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
color: cat.color,
|
||||
averageMasteryLevel: Math.round(avg * 100) / 100,
|
||||
competencyCount: catCompetencyIds.length,
|
||||
assessedCompetencyCount: assessedValues.length,
|
||||
};
|
||||
});
|
||||
|
||||
flowers.push({
|
||||
studentId: student.id,
|
||||
studentName,
|
||||
subjectId: assignment.subject.id,
|
||||
subjectName: assignment.subject.name,
|
||||
categories: categoryResults,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Quick stats
|
||||
const totalProgressEntries = progressEntries.length;
|
||||
const averageMastery =
|
||||
totalProgressEntries > 0
|
||||
? Math.round(
|
||||
(progressEntries.reduce((sum, e) => sum + e.masteryLevelValue, 0) /
|
||||
totalProgressEntries) *
|
||||
100
|
||||
) / 100
|
||||
: 0;
|
||||
|
||||
const latestGrade =
|
||||
computedGrades.length > 0
|
||||
? {
|
||||
value: computedGrades[0].overriddenValue ?? computedGrades[0].computedValue,
|
||||
period: computedGrades[0].period,
|
||||
subjectName: computedGrades[0].subject.name,
|
||||
}
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
student,
|
||||
progressEntries,
|
||||
assessmentResults,
|
||||
computedGrades,
|
||||
reports,
|
||||
flowers,
|
||||
stats: {
|
||||
totalProgressEntries,
|
||||
averageMastery,
|
||||
latestGrade,
|
||||
totalReports: reports.length,
|
||||
totalAssessments: assessmentResults.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Student detail GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const studentItemSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
dateOfBirth: z.string().optional().nullable(),
|
||||
externalId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const bulkCreateSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
classGroupId: z.string().optional(),
|
||||
schoolYearId: z.string().optional(),
|
||||
students: z.array(studentItemSchema).min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = bulkCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, classGroupId, schoolYearId, students } = parsed.data;
|
||||
|
||||
// Validate class & school year
|
||||
if (classGroupId) {
|
||||
const cls = await db.classGroup.findUnique({ where: { id: classGroupId } });
|
||||
if (!cls) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Class not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (cls.schoolId !== schoolId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Class does not belong to the specified school' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (classGroupId && !schoolYearId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'schoolYearId is required when classGroupId is provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let enrolled = 0;
|
||||
const errors: Array<{ row: number; error: string }> = [];
|
||||
|
||||
// Process each student one at a time, capturing errors per row
|
||||
for (let i = 0; i < students.length; i++) {
|
||||
const s = students[i];
|
||||
try {
|
||||
// Use externalId uniqueness if provided, otherwise just create
|
||||
let student: Awaited<ReturnType<typeof db.student.findFirst>> = null;
|
||||
if (s.externalId) {
|
||||
student = await db.student.findFirst({
|
||||
where: { externalId: s.externalId, schoolId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!student) {
|
||||
student = await db.student.create({
|
||||
data: {
|
||||
schoolId,
|
||||
firstName: s.firstName,
|
||||
lastName: s.lastName,
|
||||
dateOfBirth: s.dateOfBirth ? new Date(s.dateOfBirth) : null,
|
||||
externalId: s.externalId ?? null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
if (classGroupId && schoolYearId) {
|
||||
// Check existing enrollment before creating
|
||||
const existing = await db.enrollment.findFirst({
|
||||
where: {
|
||||
studentId: student.id,
|
||||
classGroupId,
|
||||
endDate: null,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await db.enrollment.create({
|
||||
data: {
|
||||
studentId: student.id,
|
||||
classGroupId,
|
||||
schoolYearId,
|
||||
startDate: new Date(),
|
||||
},
|
||||
});
|
||||
enrolled++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
row: i + 1,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'Student',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify({
|
||||
bulkImport: true,
|
||||
classGroupId: classGroupId ?? null,
|
||||
totalRequested: students.length,
|
||||
created,
|
||||
enrolled,
|
||||
errors: errors.length,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ created, enrolled, errors }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Bulk students POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const reorderSchema = z.object({
|
||||
classGroupId: z.string(),
|
||||
studentIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
if (session.user.role !== 'TEACHER' && session.user.role !== 'SCHOOL_ADMIN' && session.user.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = reorderSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join(', ') }, { status: 400 });
|
||||
}
|
||||
|
||||
const { classGroupId, studentIds } = parsed.data;
|
||||
|
||||
// Verify the class belongs to the teacher's school
|
||||
const classGroup = await db.classGroup.findUnique({
|
||||
where: { id: classGroupId },
|
||||
});
|
||||
if (!classGroup) {
|
||||
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
|
||||
}
|
||||
if (session.user.schoolId && classGroup.schoolId !== session.user.schoolId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.classGroup.update({
|
||||
where: { id: classGroupId },
|
||||
data: {
|
||||
seatingOrder: JSON.stringify(studentIds),
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user.schoolId,
|
||||
action: 'REORDER_STUDENTS',
|
||||
entityType: 'ClassGroup',
|
||||
entityId: classGroupId,
|
||||
metadata: JSON.stringify({ studentIds }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Reorder students error:', error);
|
||||
return NextResponse.json({ error: 'Failed to reorder students' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createStudentSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v) : undefined)),
|
||||
externalId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
const search = searchParams.get('search');
|
||||
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
|
||||
if (schoolId) where.schoolId = schoolId;
|
||||
|
||||
if (classGroupId) {
|
||||
where.enrollments = {
|
||||
some: { classGroupId, endDate: null },
|
||||
};
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: search } },
|
||||
{ lastName: { contains: search } },
|
||||
{ externalId: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const students = await db.student.findMany({
|
||||
where,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
include: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
learningProgressEntries: true,
|
||||
assessmentResults: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(students);
|
||||
} catch (error) {
|
||||
console.error('Students GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createStudentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const student = await db.student.create({
|
||||
data: parsed.data,
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(student, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Students POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const updateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
firstName: z.string().min(1).optional(),
|
||||
lastName: z.string().min(1).optional(),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v) : v === null ? null : undefined)),
|
||||
externalId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, ...updateData } = parsed.data;
|
||||
|
||||
const student = await db.student.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(student);
|
||||
} catch (error) {
|
||||
console.error('Students PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createSubjectSchema = z.object({
|
||||
schoolId: z.string().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
gradeLevelMin: z.number().int().default(1),
|
||||
gradeLevelMax: z.number().int().default(13),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
|
||||
// Show global subjects (schoolId=null) and school-specific ones
|
||||
const where: Record<string, unknown> = {};
|
||||
if (schoolId) {
|
||||
where.OR = [
|
||||
{ schoolId: null },
|
||||
{ schoolId },
|
||||
];
|
||||
}
|
||||
|
||||
const subjects = await db.subject.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(subjects);
|
||||
} catch (error) {
|
||||
console.error('Subjects GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSubjectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const subject = await db.subject.create({
|
||||
data: {
|
||||
schoolId: parsed.data.schoolId ?? null,
|
||||
name: parsed.data.name,
|
||||
gradeLevelMin: parsed.data.gradeLevelMin,
|
||||
gradeLevelMax: parsed.data.gradeLevelMax,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(subject, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Subjects POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSubjectSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
gradeLevelMin: z.number().int().optional(),
|
||||
gradeLevelMax: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateSubjectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id, ...updateData } = parsed.data;
|
||||
|
||||
const subject = await db.subject.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Subject',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify(updateData),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(subject);
|
||||
} catch (error) {
|
||||
console.error('Subjects PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id query parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if subject is used in any assessments or competency assignments
|
||||
const usageCount = await db.classCompetencyAssignment.count({
|
||||
where: { subjectId: id },
|
||||
});
|
||||
|
||||
if (usageCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Subject is in use and cannot be deleted', usageCount },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.subject.delete({ where: { id } });
|
||||
|
||||
// Create audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'DELETE',
|
||||
entityType: 'Subject',
|
||||
entityId: id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Subjects DELETE error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const noteCategoryEnum = z.enum([
|
||||
'GENERAL',
|
||||
'BEHAVIOR',
|
||||
'ACADEMIC',
|
||||
'INTERVENTION',
|
||||
'PARENT_CONTACT',
|
||||
]);
|
||||
|
||||
const updateNoteSchema = z.object({
|
||||
category: noteCategoryEnum.optional(),
|
||||
content: z.string().min(1, 'Content cannot be empty').optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateNoteSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.teacherNote.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only the author (or an admin of the school) can edit
|
||||
const isAuthor = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (parsed.data.category !== undefined) data.category = parsed.data.category;
|
||||
if (parsed.data.content !== undefined) data.content = parsed.data.content.trim();
|
||||
if (parsed.data.isPrivate !== undefined) data.isPrivate = parsed.data.isPrivate;
|
||||
|
||||
const note = await db.teacherNote.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
studentId: existing.studentId,
|
||||
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
|
||||
changes: parsed.data,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note);
|
||||
} catch (error) {
|
||||
console.error('TeacherNote PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.teacherNote.findUnique({
|
||||
where: { id },
|
||||
include: { student: { select: { schoolId: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isAuthor = existing.teacherId === session.userId;
|
||||
const isSchoolAdmin =
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.student.schoolId === session.user.schoolId;
|
||||
const isSuperAdmin = session.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
if (!isAuthor && !isSchoolAdmin && !isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.teacherNote.delete({ where: { id } });
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.student.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
studentId: existing.studentId,
|
||||
studentName: `${existing.student.firstName} ${existing.student.lastName}`,
|
||||
category: existing.category,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('TeacherNote DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const noteCategoryEnum = z.enum([
|
||||
'GENERAL',
|
||||
'BEHAVIOR',
|
||||
'ACADEMIC',
|
||||
'INTERVENTION',
|
||||
'PARENT_CONTACT',
|
||||
]);
|
||||
|
||||
const createNoteSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
category: noteCategoryEnum,
|
||||
content: z.string().min(1, 'Content cannot be empty'),
|
||||
isPrivate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only teachers + admins may view private notes
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const studentId = searchParams.get('studentId');
|
||||
|
||||
if (!studentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'studentId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the student belongs to a school the user can see
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const notes = await db.teacherNote.findMany({
|
||||
where: { studentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error('TeacherNotes GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createNoteSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { studentId, category, content, isPrivate = true } = parsed.data;
|
||||
|
||||
// Verify student exists and is in scope
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
select: { id: true, schoolId: true, firstName: true, lastName: true },
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
student.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const note = await db.teacherNote.create({
|
||||
data: {
|
||||
studentId,
|
||||
teacherId: session.userId,
|
||||
category,
|
||||
content: content.trim(),
|
||||
isPrivate,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: student.schoolId,
|
||||
action: 'CREATE',
|
||||
entityType: 'TeacherNote',
|
||||
entityId: note.id,
|
||||
metadata: JSON.stringify({
|
||||
studentId,
|
||||
category,
|
||||
studentName: `${student.firstName} ${student.lastName}`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('TeacherNotes POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const assignClassesSchema = z.object({
|
||||
classGroupIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
// GET — list all class assignments + available classes for selection
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
user.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const assignments = await db.classGroupTeacher.findMany({
|
||||
where: { userId: id },
|
||||
include: {
|
||||
classGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gradeLevel: true,
|
||||
schoolType: true,
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const availableClasses = user.schoolId
|
||||
? await db.classGroup.findMany({
|
||||
where: { schoolId: user.schoolId },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
gradeLevel: true,
|
||||
schoolType: true,
|
||||
schoolYear: { select: { id: true, label: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
return NextResponse.json({
|
||||
assignments,
|
||||
availableClasses,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User classes GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST — assign teacher to a set of classes (replace all)
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = assignClassesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, schoolId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
user.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Validate that all class groups belong to the user's school
|
||||
const targetClassIds = parsed.data.classGroupIds;
|
||||
if (targetClassIds.length > 0) {
|
||||
const validClasses = await db.classGroup.findMany({
|
||||
where: {
|
||||
id: { in: targetClassIds },
|
||||
...(user.schoolId ? { schoolId: user.schoolId } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (validClasses.length !== targetClassIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some classes were not found in this school' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace all existing teacher assignments with the new list.
|
||||
// Use a transaction to keep this atomic.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.classGroupTeacher.deleteMany({
|
||||
where: { userId: id },
|
||||
});
|
||||
|
||||
if (targetClassIds.length > 0) {
|
||||
await tx.classGroupTeacher.createMany({
|
||||
data: targetClassIds.map((classGroupId) => ({
|
||||
classGroupId,
|
||||
userId: id,
|
||||
role: 'SUBJECT_TEACHER',
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: user.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
assignedClassIds: targetClassIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
assigned: targetClassIds.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User classes POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
firstName: z.string().min(1).optional(),
|
||||
lastName: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
classGroupTeachers: {
|
||||
include: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
classGroupTeachers: true,
|
||||
learningProgressEntries: true,
|
||||
assessments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// School admins can only access users from their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
user.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
|
||||
return NextResponse.json(safeUser);
|
||||
} catch (error) {
|
||||
console.error('User GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// School admins can only edit users from their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// School admins cannot promote to super admin
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
parsed.data.role === 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot assign super admin role' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check email uniqueness if changed
|
||||
if (parsed.data.email && parsed.data.email !== existing.email) {
|
||||
const emailTaken = await db.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
});
|
||||
if (emailTaken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email already registered' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await db.user.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
classGroupTeachers: {
|
||||
include: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: user.schoolId,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify(parsed.data),
|
||||
},
|
||||
});
|
||||
|
||||
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
|
||||
return NextResponse.json(safeUser);
|
||||
} catch (error) {
|
||||
console.error('User PUT error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (id === session.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete your own account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// School admins can only delete users from their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
existing.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Soft delete by setting deletedAt timestamp
|
||||
await db.user.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: existing.schoolId,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
metadata: JSON.stringify({
|
||||
email: existing.email,
|
||||
firstName: existing.firstName,
|
||||
lastName: existing.lastName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('User DELETE error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession, hashPassword } from '@/lib/auth';
|
||||
|
||||
const createUserSchema = z.object({
|
||||
schoolId: z.string().nullable().optional(),
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(8),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
|
||||
locale: z.string().default('de'),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
|
||||
// Non-super admins can only see users from their own school
|
||||
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
|
||||
where.schoolId = session.user.schoolId;
|
||||
} else if (schoolId) {
|
||||
where.schoolId = schoolId;
|
||||
}
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
classGroupTeachers: {
|
||||
include: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
classGroupTeachers: true,
|
||||
learningProgressEntries: true,
|
||||
assessments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Strip password hashes before returning
|
||||
const safeUsers = users.map(({ passwordHash: _stripped, ...rest }) => rest);
|
||||
|
||||
return NextResponse.json(safeUsers);
|
||||
} catch (error) {
|
||||
console.error('Users GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { password, ...userData } = parsed.data;
|
||||
|
||||
// School admins can only create users in their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
userData.schoolId &&
|
||||
userData.schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create users outside your school' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// School admins cannot create super admin accounts
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
userData.role === 'SUPER_ADMIN'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create super admin accounts' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existingEmail = await db.user.findUnique({
|
||||
where: { email: userData.email },
|
||||
});
|
||||
if (existingEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email already registered' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
...userData,
|
||||
passwordHash,
|
||||
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
|
||||
},
|
||||
include: {
|
||||
school: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
|
||||
action: 'CREATE',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
metadata: JSON.stringify({
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Strip password hash from response
|
||||
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
|
||||
return NextResponse.json(safeUser, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Users POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-emerald: #10b981;
|
||||
--color-teal: #14b8a6;
|
||||
--color-amber: #f59e0b;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: oklch(0.99 0.002 155);
|
||||
--foreground: oklch(0.17 0.01 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.17 0.01 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.17 0.01 260);
|
||||
--primary: oklch(0.55 0.17 155);
|
||||
--primary-foreground: oklch(0.98 0.005 155);
|
||||
--secondary: oklch(0.96 0.01 155);
|
||||
--secondary-foreground: oklch(0.25 0.02 260);
|
||||
--muted: oklch(0.96 0.005 155);
|
||||
--muted-foreground: oklch(0.55 0.02 260);
|
||||
--accent: oklch(0.96 0.01 155);
|
||||
--accent-foreground: oklch(0.25 0.02 260);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.93 0.005 155);
|
||||
--input: oklch(0.93 0.005 155);
|
||||
--ring: oklch(0.55 0.17 155);
|
||||
--chart-1: oklch(0.55 0.17 155);
|
||||
--chart-2: oklch(0.53 0.14 175);
|
||||
--chart-3: oklch(0.72 0.19 85);
|
||||
--chart-4: oklch(0.60 0.18 50);
|
||||
--chart-5: oklch(0.65 0.22 330);
|
||||
--sidebar: oklch(0.97 0.01 155);
|
||||
--sidebar-foreground: oklch(0.17 0.01 260);
|
||||
--sidebar-primary: oklch(0.55 0.17 155);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.005 155);
|
||||
--sidebar-accent: oklch(0.94 0.02 155);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 260);
|
||||
--sidebar-border: oklch(0.93 0.005 155);
|
||||
--sidebar-ring: oklch(0.55 0.17 155);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.13 0.01 260);
|
||||
--foreground: oklch(0.98 0.005 155);
|
||||
--card: oklch(0.18 0.015 260);
|
||||
--card-foreground: oklch(0.98 0.005 155);
|
||||
--popover: oklch(0.18 0.015 260);
|
||||
--popover-foreground: oklch(0.98 0.005 155);
|
||||
--primary: oklch(0.65 0.17 155);
|
||||
--primary-foreground: oklch(0.15 0.01 260);
|
||||
--secondary: oklch(0.24 0.02 260);
|
||||
--secondary-foreground: oklch(0.98 0.005 155);
|
||||
--muted: oklch(0.24 0.02 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
--accent: oklch(0.24 0.02 260);
|
||||
--accent-foreground: oklch(0.98 0.005 155);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.65 0.17 155);
|
||||
--chart-1: oklch(0.65 0.17 155);
|
||||
--chart-2: oklch(0.63 0.14 175);
|
||||
--chart-3: oklch(0.77 0.19 85);
|
||||
--chart-4: oklch(0.72 0.22 50);
|
||||
--chart-5: oklch(0.70 0.22 330);
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.98 0.005 155);
|
||||
--sidebar-primary: oklch(0.65 0.17 155);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.01 260);
|
||||
--sidebar-accent: oklch(0.24 0.02 260);
|
||||
--sidebar-accent-foreground: oklch(0.98 0.005 155);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.65 0.17 155);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
@layer utilities {
|
||||
.scrollbar-education::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-education::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-education::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.80 0.02 155);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-education::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.65 0.17 155);
|
||||
}
|
||||
.dark .scrollbar-education::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.35 0.02 260);
|
||||
}
|
||||
.dark .scrollbar-education::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.65 0.17 155);
|
||||
}
|
||||
|
||||
/* Global scrollbar override */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.85 0.01 155);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.65 0.17 155);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.30 0.015 260);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.50 0.10 155);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Dot grid background pattern */
|
||||
.bg-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.80 0.02 155) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.dark .bg-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.25 0.015 260) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
.interactive-transition {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Animated gradient background */
|
||||
.animated-gradient-bg {
|
||||
background: linear-gradient(135deg, oklch(0.97 0.01 155) 0%, oklch(0.99 0.002 155) 50%, oklch(0.97 0.01 175) 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
.dark .animated-gradient-bg {
|
||||
background: linear-gradient(135deg, oklch(0.15 0.01 260) 0%, oklch(0.13 0.01 260) 50%, oklch(0.15 0.01 155) 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.85; }
|
||||
}
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Shimmer animation for progress bars */
|
||||
@keyframes shimmer-bar {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer-bar 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Float animation for geometric shapes */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(-15px) rotate(5deg); }
|
||||
66% { transform: translateY(10px) rotate(-3deg); }
|
||||
}
|
||||
@keyframes float-reverse {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(12px) rotate(-4deg); }
|
||||
66% { transform: translateY(-10px) rotate(6deg); }
|
||||
}
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(8deg); }
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
.animate-float-reverse {
|
||||
animation: float-reverse 7s ease-in-out infinite;
|
||||
}
|
||||
.animate-float-slow {
|
||||
animation: float-slow 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Progress bar animation */
|
||||
@keyframes progress-fill {
|
||||
from { width: 0; }
|
||||
}
|
||||
.animate-progress-fill {
|
||||
animation: progress-fill 1s ease-out;
|
||||
}
|
||||
|
||||
/* Print styles — full print stylesheet lives at end of file */
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
@layer base {
|
||||
*:focus-visible {
|
||||
outline: 2px solid oklch(0.55 0.17 155);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
/* Make keyboard focus more visible on interactive controls */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid oklch(0.55 0.17 155);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.18);
|
||||
transition: box-shadow 0.15s ease-out;
|
||||
}
|
||||
.dark button:focus-visible,
|
||||
.dark a:focus-visible,
|
||||
.dark [role="button"]:focus-visible,
|
||||
.dark input:focus-visible,
|
||||
.dark select:focus-visible,
|
||||
.dark textarea:focus-visible,
|
||||
.dark [tabindex]:focus-visible {
|
||||
outline: 2px solid oklch(0.65 0.17 155);
|
||||
box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.25);
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Styling polish utilities ─────────────────────────────────────────── */
|
||||
|
||||
@layer utilities {
|
||||
/* Subtle paper texture for main backgrounds */
|
||||
.bg-paper {
|
||||
background-color: oklch(0.99 0.002 155);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 10%, oklch(0.96 0.015 155 / 0.6) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 80%, oklch(0.96 0.012 175 / 0.5) 0%, transparent 45%),
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.45 0 0 0 0 0.3 0 0 0 0.025 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
.dark .bg-paper {
|
||||
background-color: oklch(0.13 0.01 260);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 10%, oklch(0.18 0.02 155 / 0.35) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 80%, oklch(0.18 0.02 175 / 0.3) 0%, transparent 45%),
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.55 0 0 0 0 0.85 0 0 0 0 0.7 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Mesh gradient for hero / stats areas */
|
||||
.bg-mesh {
|
||||
background-color: oklch(0.99 0.002 155);
|
||||
background-image:
|
||||
radial-gradient(at 12% 18%, oklch(0.94 0.04 155 / 0.55) 0px, transparent 50%),
|
||||
radial-gradient(at 88% 22%, oklch(0.94 0.05 175 / 0.45) 0px, transparent 50%),
|
||||
radial-gradient(at 22% 88%, oklch(0.95 0.05 85 / 0.4) 0px, transparent 50%),
|
||||
radial-gradient(at 78% 78%, oklch(0.95 0.05 330 / 0.3) 0px, transparent 50%);
|
||||
}
|
||||
.dark .bg-mesh {
|
||||
background-color: oklch(0.13 0.01 260);
|
||||
background-image:
|
||||
radial-gradient(at 12% 18%, oklch(0.20 0.06 155 / 0.35) 0px, transparent 50%),
|
||||
radial-gradient(at 88% 22%, oklch(0.20 0.06 175 / 0.3) 0px, transparent 50%),
|
||||
radial-gradient(at 22% 88%, oklch(0.20 0.07 85 / 0.25) 0px, transparent 50%),
|
||||
radial-gradient(at 78% 78%, oklch(0.20 0.08 330 / 0.2) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
/* Shimmer skeleton loader */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(0.92 0.005 155 / 0.4) 0%,
|
||||
oklch(0.88 0.01 155 / 0.7) 50%,
|
||||
oklch(0.92 0.005 155 / 0.4) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
}
|
||||
.dark .shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(0.22 0.015 260 / 0.5) 0%,
|
||||
oklch(0.28 0.02 260 / 0.8) 50%,
|
||||
oklch(0.22 0.015 260 / 0.5) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
/* Subtle inner ring for premium cards */
|
||||
.ring-inset-soft {
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.05), 0 1px 2px oklch(0 0 0 / 0.04);
|
||||
}
|
||||
.dark .ring-inset-soft {
|
||||
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Document / paper preview look for reports */
|
||||
.paper-preview {
|
||||
background: white;
|
||||
background-image:
|
||||
linear-gradient(oklch(0.96 0.005 155 / 0.6) 1px, transparent 1px);
|
||||
background-size: 100% 28px;
|
||||
box-shadow:
|
||||
0 1px 0 oklch(0.85 0.005 155),
|
||||
0 4px 12px oklch(0 0 0 / 0.08),
|
||||
0 24px 60px oklch(0 0 0 / 0.06);
|
||||
}
|
||||
.dark .paper-preview {
|
||||
background: oklch(0.18 0.015 260);
|
||||
background-image:
|
||||
linear-gradient(oklch(0.22 0.015 260 / 0.6) 1px, transparent 1px);
|
||||
background-size: 100% 28px;
|
||||
box-shadow:
|
||||
0 1px 0 oklch(0 0 0 / 0.4),
|
||||
0 4px 12px oklch(0 0 0 / 0.4),
|
||||
0 24px 60px oklch(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Sticky header helper for tables */
|
||||
.sticky-header th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: oklch(0.96 0.005 155);
|
||||
}
|
||||
.dark .sticky-header th {
|
||||
background: oklch(0.20 0.015 260);
|
||||
}
|
||||
|
||||
/* Sparkline path */
|
||||
.sparkline-path {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Lift on hover for cards */
|
||||
.lift-on-hover {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.lift-on-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px -10px oklch(0.55 0.17 155 / 0.18);
|
||||
}
|
||||
|
||||
/* Gradient text for highlights */
|
||||
.text-gradient-emerald {
|
||||
background: linear-gradient(90deg, oklch(0.55 0.17 155), oklch(0.55 0.14 175));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.text-gradient-amber {
|
||||
background: linear-gradient(90deg, oklch(0.72 0.19 85), oklch(0.60 0.18 50));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.text-gradient-violet {
|
||||
background: linear-gradient(90deg, oklch(0.60 0.20 320), oklch(0.65 0.22 280));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.text-gradient-rose {
|
||||
background: linear-gradient(90deg, oklch(0.65 0.22 15), oklch(0.62 0.20 350));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.text-gradient-teal {
|
||||
background: linear-gradient(90deg, oklch(0.55 0.14 175), oklch(0.60 0.16 195));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
/* Generic gradient text — direction aware */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, oklch(0.55 0.17 155) 0%, oklch(0.55 0.14 175) 50%, oklch(0.65 0.22 330) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.dark .gradient-text {
|
||||
background: linear-gradient(135deg, oklch(0.70 0.17 155) 0%, oklch(0.70 0.14 175) 50%, oklch(0.78 0.22 330) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Frosted glass card — backdrop blur + translucent surface */
|
||||
.glass-card {
|
||||
background: oklch(1 0 0 / 0.7);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
border: 1px solid oklch(1 0 0 / 0.4);
|
||||
box-shadow:
|
||||
0 4px 16px -4px oklch(0 0 0 / 0.08),
|
||||
inset 0 0 0 1px oklch(1 0 0 / 0.3);
|
||||
}
|
||||
.dark .glass-card {
|
||||
background: oklch(0.18 0.015 260 / 0.65);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
border: 1px solid oklch(1 0 0 / 0.08);
|
||||
box-shadow:
|
||||
0 4px 16px -4px oklch(0 0 0 / 0.4),
|
||||
inset 0 0 0 1px oklch(1 0 0 / 0.05);
|
||||
}
|
||||
.glass-card-strong {
|
||||
background: oklch(1 0 0 / 0.85);
|
||||
backdrop-filter: blur(20px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(200%);
|
||||
border: 1px solid oklch(1 0 0 / 0.5);
|
||||
}
|
||||
.dark .glass-card-strong {
|
||||
background: oklch(0.18 0.015 260 / 0.8);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border: 1px solid oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Animated count-up number */
|
||||
@keyframes count-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-count-up {
|
||||
animation: count-up 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Decorative dotted/grid background pattern for headers */
|
||||
.bg-pattern-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.55 0.17 155 / 0.18) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
.dark .bg-pattern-dots {
|
||||
background-image: radial-gradient(circle, oklch(0.65 0.17 155 / 0.18) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
.bg-pattern-grid {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.55 0.17 155 / 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.55 0.17 155 / 0.06) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.dark .bg-pattern-grid {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.65 0.17 155 / 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.65 0.17 155 / 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* Soft glow utility for highlighted elements */
|
||||
.glow-emerald {
|
||||
box-shadow: 0 0 0 1px oklch(0.55 0.17 155 / 0.15), 0 8px 24px -6px oklch(0.55 0.17 155 / 0.35);
|
||||
}
|
||||
.dark .glow-emerald {
|
||||
box-shadow: 0 0 0 1px oklch(0.65 0.17 155 / 0.25), 0 8px 24px -6px oklch(0.65 0.17 155 / 0.45);
|
||||
}
|
||||
|
||||
/* Subtle top-border accent for chart cards */
|
||||
.accent-top-emerald { border-top: 3px solid oklch(0.55 0.17 155); }
|
||||
.accent-top-teal { border-top: 3px solid oklch(0.55 0.14 175); }
|
||||
.accent-top-amber { border-top: 3px solid oklch(0.72 0.19 85); }
|
||||
.accent-top-violet { border-top: 3px solid oklch(0.60 0.20 320); }
|
||||
.accent-top-rose { border-top: 3px solid oklch(0.65 0.22 15); }
|
||||
|
||||
/* Subtle entrance animation for staggered items */
|
||||
@keyframes enter-fade {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-enter-fade {
|
||||
animation: enter-fade 0.35s ease-out backwards;
|
||||
}
|
||||
|
||||
/* ─── Styling polish additions ──────────────────────────────────── */
|
||||
|
||||
/* Smooth page transition animation (fadeIn for view changes) */
|
||||
@keyframes view-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-view-fade-in {
|
||||
animation: view-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Hover lift effect for cards */
|
||||
.card-hover-lift {
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
}
|
||||
.card-hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.15);
|
||||
}
|
||||
.dark .card-hover-lift:hover {
|
||||
box-shadow: 0 8px 24px -6px oklch(0.65 0.17 155 / 0.25);
|
||||
}
|
||||
|
||||
/* Focus-within ring animation for inputs */
|
||||
.input-focus-ring {
|
||||
transition: box-shadow 0.2s ease-out;
|
||||
}
|
||||
.input-focus-ring:focus-within {
|
||||
box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.2);
|
||||
}
|
||||
.dark .input-focus-ring:focus-within {
|
||||
box-shadow: 0 0 0 3px oklch(0.65 0.17 155 / 0.3);
|
||||
}
|
||||
|
||||
/* Tooltip animation (scale from 0.95 to 1) */
|
||||
@keyframes tooltip-enter {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-tooltip-enter {
|
||||
animation: tooltip-enter 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print-specific styling — expands to all reports when printing */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
body { background: white !important; color: black !important; }
|
||||
|
||||
/* Hide sidebar, header, navigation */
|
||||
[data-sidebar], header, nav, .no-print { display: none !important; }
|
||||
|
||||
/* Make main content full width */
|
||||
main, .print-area { width: 100% !important; margin: 0 !important; padding: 1rem !important; }
|
||||
|
||||
/* Ensure cards have borders for print */
|
||||
.card, [class*="Card"] { border: 1px solid #e5e7eb !important; break-inside: avoid; }
|
||||
|
||||
/* Ensure text is readable */
|
||||
body { color: #000 !important; background: #fff !important; }
|
||||
|
||||
/* Page breaks */
|
||||
.page-break { page-break-before: always; }
|
||||
.print-break-before { page-break-before: always; }
|
||||
.print-avoid-break { page-break-inside: avoid; }
|
||||
|
||||
.shadow-sm, .shadow-md, .shadow-lg, .shadow-xl { box-shadow: none !important; }
|
||||
.paper-preview { box-shadow: none !important; }
|
||||
@page { margin: 18mm 16mm; }
|
||||
/* Better margins for print */
|
||||
.card-hover-lift:hover { transform: none !important; box-shadow: none !important; }
|
||||
/* Hide interactive elements */
|
||||
button, .no-print, [role="button"], nav, aside, footer, header,
|
||||
.sidebar, .sidebar-trigger, .breadcrumb { display: none !important; }
|
||||
/* Ensure readable text */
|
||||
.dark { background: white !important; color: black !important; }
|
||||
.dark * { color: inherit !important; background: transparent !important; }
|
||||
/* Prevent overflow */
|
||||
.overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 { max-height: none !important; overflow: visible !important; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CompetenceTrack — Free Competency & Grading Platform",
|
||||
description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.",
|
||||
keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"],
|
||||
authors: [{ name: "CompetenceTrack Team" }],
|
||||
icons: {
|
||||
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { fetchCurrentUser } from '@/lib/api';
|
||||
import AuthView from '@/components/auth-view';
|
||||
import AppLayout from '@/components/app-layout';
|
||||
|
||||
export default function Home() {
|
||||
const currentUser = useAppStore((s) => s.currentUser);
|
||||
const isLoadingAuth = useAppStore((s) => s.isLoadingAuth);
|
||||
const setCurrentUser = useAppStore((s) => s.setCurrentUser);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const user = await fetchCurrentUser();
|
||||
if (user && !user.error) {
|
||||
setCurrentUser(user);
|
||||
// Sync locale from user preference
|
||||
if (user.locale) {
|
||||
useAppStore.getState().setLocale(user.locale);
|
||||
}
|
||||
} else {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
} catch {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
}
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<span className="text-gray-500 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <AuthView />;
|
||||
}
|
||||
|
||||
return <AppLayout />;
|
||||
}
|
||||
Reference in new issue
Block a user