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:
Mustafa Cetin committed 2026-07-29 05:46:10 +00:00
commit 0c1f739989
156 files changed
+51090

No files matched your search

+439
View File
@@ -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 }
);
}
}
+133
View File
@@ -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 }
);
}
}
+146
View File
@@ -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 });
}
}
+298
View File
@@ -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 });
}
}
+52
View File
@@ -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 });
}
}
+157
View File
@@ -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 });
}
}
+137
View File
@@ -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 });
}
}
+224
View File
@@ -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 });
}
}
+326
View File
@@ -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 }
);
}
}
+127
View File
@@ -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 }
);
}
}
+204
View File
@@ -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 }
);
}
}
+212
View File
@@ -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 });
}
}
+183
View File
@@ -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 });
}
}
+130
View File
@@ -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 });
}
}
+229
View File
@@ -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 }
);
}
}
+197
View File
@@ -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 }
);
}
}
+332
View File
@@ -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 });
}
}
+209
View File
@@ -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 }
);
}
}
+280
View File
@@ -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 });
}
}
+184
View File
@@ -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 }
);
}
}
+96
View File
@@ -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 });
}
}
+116
View File
@@ -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 });
}
}
+371
View File
@@ -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 });
}
}
+130
View File
@@ -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 }
);
}
}
+230
View File
@@ -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 });
}
}
+192
View File
@@ -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 });
}
}
+187
View File
@@ -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 });
}
}
+185
View File
@@ -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;
}
+293
View File
@@ -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 });
}
}
+252
View File
@@ -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 });
}
}
+205
View File
@@ -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 });
}
}
+196
View File
@@ -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 });
}
}
+204
View File
@@ -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>`;
}
+214
View File
@@ -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 }
);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
+111
View File
@@ -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 });
}
}
+246
View File
@@ -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 });
}
}
+216
View File
@@ -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 });
}
}
+85
View File
@@ -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 }
);
}
}
+164
View File
@@ -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 }
);
}
}
+279
View File
@@ -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 }
);
}
}
+155
View File
@@ -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 }
);
}
}
+70
View File
@@ -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 });
}
}
+174
View File
@@ -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 }
);
}
}
+202
View File
@@ -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 }
);
}
}
+158
View File
@@ -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 });
}
}
+149
View File
@@ -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 });
}
}
+194
View File
@@ -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 }
);
}
}
+250
View File
@@ -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 }
);
}
}
+177
View File
@@ -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 }
);
}
}
+635
View File
@@ -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; }
}
+49
View File
@@ -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>
);
}
+50
View File
@@ -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 />;
}