Round 11: DrawingCanvas Integration, Notebook Versioning, Student Auth, Parent Portal, Calendar Recurring, Grade PDF, Data Import/Export
This commit is contained in:
1 parent
b90ad85b91
commit
c3d36aac67
22 files changed
+3984
-314
No files matched your search
@@ -1,6 +1,107 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { addDays, addWeeks, addMonths, addYears, getDay } from 'date-fns';
|
||||
|
||||
interface RecurrencePattern {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval: number;
|
||||
endDate?: string;
|
||||
daysOfWeek?: number[];
|
||||
}
|
||||
|
||||
function generateChildEvents(
|
||||
parentEvent: {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
teacherId: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
eventType: string;
|
||||
subjectId: string | null;
|
||||
classGroupId: string | null;
|
||||
notes: string | null;
|
||||
allDay: boolean;
|
||||
},
|
||||
pattern: RecurrencePattern,
|
||||
recurrenceEnd: Date | null
|
||||
) {
|
||||
const children: Array<{
|
||||
schoolId: string;
|
||||
teacherId: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
eventType: string;
|
||||
subjectId: string | null;
|
||||
classGroupId: string | null;
|
||||
notes: string | null;
|
||||
allDay: boolean;
|
||||
parentEventId: string;
|
||||
recurrencePattern: string;
|
||||
}> = [];
|
||||
|
||||
const maxDate = recurrenceEnd ?? new Date(pattern.endDate ?? '2026-12-31');
|
||||
const patternJson = JSON.stringify(pattern);
|
||||
let currentDate = parentEvent.date;
|
||||
let count = 0;
|
||||
const maxOccurrences = 365;
|
||||
|
||||
while (count < maxOccurrences) {
|
||||
if (pattern.type === 'daily') {
|
||||
currentDate = addDays(currentDate, pattern.interval);
|
||||
} else if (pattern.type === 'weekly') {
|
||||
if (pattern.daysOfWeek && pattern.daysOfWeek.length > 0) {
|
||||
let nextDate = addDays(currentDate, 1);
|
||||
let found = false;
|
||||
let weekCounter = 0;
|
||||
while (!found && weekCounter < 7 * pattern.interval) {
|
||||
if (pattern.daysOfWeek.includes(getDay(nextDate))) {
|
||||
currentDate = nextDate;
|
||||
found = true;
|
||||
} else {
|
||||
nextDate = addDays(nextDate, 1);
|
||||
weekCounter++;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
currentDate = addWeeks(currentDate, pattern.interval);
|
||||
}
|
||||
} else {
|
||||
currentDate = addWeeks(currentDate, pattern.interval);
|
||||
}
|
||||
} else if (pattern.type === 'monthly') {
|
||||
currentDate = addMonths(currentDate, pattern.interval);
|
||||
} else if (pattern.type === 'yearly') {
|
||||
currentDate = addYears(currentDate, pattern.interval);
|
||||
}
|
||||
|
||||
if (currentDate > maxDate) break;
|
||||
|
||||
children.push({
|
||||
schoolId: parentEvent.schoolId,
|
||||
teacherId: parentEvent.teacherId,
|
||||
title: parentEvent.title,
|
||||
date: new Date(currentDate),
|
||||
startTime: parentEvent.startTime,
|
||||
endTime: parentEvent.endTime,
|
||||
eventType: parentEvent.eventType,
|
||||
subjectId: parentEvent.subjectId,
|
||||
classGroupId: parentEvent.classGroupId,
|
||||
notes: parentEvent.notes,
|
||||
allDay: parentEvent.allDay,
|
||||
parentEventId: parentEvent.id,
|
||||
recurrencePattern: patternJson,
|
||||
});
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
@@ -11,13 +112,50 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay } = body;
|
||||
const {
|
||||
title, date, startTime, endTime, eventType, subjectId, classGroupId,
|
||||
notes, allDay, recurrencePattern, recurrenceEnd, editMode,
|
||||
} = body;
|
||||
|
||||
const existing = await db.calendarEvent.findUnique({ where: { id } });
|
||||
const existing = await db.calendarEvent.findUnique({
|
||||
where: { id },
|
||||
include: { childEvents: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// If editing a single instance of a recurring event
|
||||
if (editMode === 'instance' && existing.parentEventId) {
|
||||
const event = await db.calendarEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: title ?? existing.title,
|
||||
date: date ? new Date(date) : existing.date,
|
||||
startTime: startTime !== undefined ? startTime : existing.startTime,
|
||||
endTime: endTime !== undefined ? endTime : existing.endTime,
|
||||
eventType: eventType ?? existing.eventType,
|
||||
subjectId: subjectId !== undefined ? subjectId : existing.subjectId,
|
||||
classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId,
|
||||
notes: notes !== undefined ? notes : existing.notes,
|
||||
allDay: allDay !== undefined ? allDay : existing.allDay,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json(event);
|
||||
}
|
||||
|
||||
// If editing the series (parent event)
|
||||
let parsedPattern: RecurrencePattern | null = null;
|
||||
if (recurrencePattern && recurrencePattern.type && recurrencePattern.type !== 'none') {
|
||||
parsedPattern = recurrencePattern as RecurrencePattern;
|
||||
}
|
||||
const parsedRecurrenceEnd = recurrenceEnd ? new Date(recurrenceEnd) : null;
|
||||
|
||||
// Update the parent event
|
||||
const event = await db.calendarEvent.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -30,6 +168,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId,
|
||||
notes: notes !== undefined ? notes : existing.notes,
|
||||
allDay: allDay !== undefined ? allDay : existing.allDay,
|
||||
recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null,
|
||||
recurrenceEnd: parsedRecurrenceEnd,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
@@ -37,7 +177,42 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(event);
|
||||
// Delete existing child events and regenerate
|
||||
if (existing.childEvents.length > 0) {
|
||||
await db.calendarEvent.deleteMany({
|
||||
where: { parentEventId: id },
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new child events if recurring
|
||||
let childCount = 0;
|
||||
if (parsedPattern) {
|
||||
const childEvents = generateChildEvents(
|
||||
{
|
||||
id: event.id,
|
||||
schoolId: event.schoolId,
|
||||
teacherId: event.teacherId,
|
||||
title: event.title,
|
||||
date: event.date,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
eventType: event.eventType,
|
||||
subjectId: event.subjectId,
|
||||
classGroupId: event.classGroupId,
|
||||
notes: event.notes,
|
||||
allDay: event.allDay,
|
||||
},
|
||||
parsedPattern,
|
||||
parsedRecurrenceEnd
|
||||
);
|
||||
|
||||
if (childEvents.length > 0) {
|
||||
await db.calendarEvent.createMany({ data: childEvents });
|
||||
childCount = childEvents.length;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...event, childCount });
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent PUT error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -52,12 +227,45 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const deleteMode = searchParams.get('mode'); // 'series' or 'instance'
|
||||
|
||||
const existing = await db.calendarEvent.findUnique({ where: { id } });
|
||||
const existing = await db.calendarEvent.findUnique({
|
||||
where: { id },
|
||||
include: { childEvents: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// If deleting a single instance, just delete that one
|
||||
if (deleteMode === 'instance') {
|
||||
await db.calendarEvent.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// If this is a parent recurring event, delete all children too
|
||||
if (existing.childEvents.length > 0) {
|
||||
await db.calendarEvent.deleteMany({
|
||||
where: { parentEventId: id },
|
||||
});
|
||||
}
|
||||
|
||||
// If this is a child event and deleting series, delete parent and all siblings
|
||||
if (existing.parentEventId && deleteMode === 'series') {
|
||||
const parent = await db.calendarEvent.findUnique({
|
||||
where: { id: existing.parentEventId },
|
||||
include: { childEvents: true },
|
||||
});
|
||||
if (parent) {
|
||||
await db.calendarEvent.deleteMany({
|
||||
where: { parentEventId: parent.id },
|
||||
});
|
||||
await db.calendarEvent.delete({ where: { id: parent.id } });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
await db.calendarEvent.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { addDays, addWeeks, addMonths, addYears, format, getDay } from 'date-fns';
|
||||
|
||||
interface RecurrencePattern {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval: number;
|
||||
endDate?: string;
|
||||
daysOfWeek?: number[]; // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
}
|
||||
|
||||
function generateChildEvents(
|
||||
parentEvent: {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
teacherId: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
eventType: string;
|
||||
subjectId: string | null;
|
||||
classGroupId: string | null;
|
||||
notes: string | null;
|
||||
allDay: boolean;
|
||||
},
|
||||
pattern: RecurrencePattern,
|
||||
recurrenceEnd: Date | null
|
||||
) {
|
||||
const children: Array<{
|
||||
schoolId: string;
|
||||
teacherId: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
eventType: string;
|
||||
subjectId: string | null;
|
||||
classGroupId: string | null;
|
||||
notes: string | null;
|
||||
allDay: boolean;
|
||||
parentEventId: string;
|
||||
recurrencePattern: string;
|
||||
}> = [];
|
||||
|
||||
const maxDate = recurrenceEnd ?? new Date(pattern.endDate ?? '2026-12-31');
|
||||
const patternJson = JSON.stringify(pattern);
|
||||
let currentDate = parentEvent.date;
|
||||
|
||||
// Generate up to 365 occurrences to prevent infinite loops
|
||||
let count = 0;
|
||||
const maxOccurrences = 365;
|
||||
|
||||
while (count < maxOccurrences) {
|
||||
// Advance to next occurrence
|
||||
if (pattern.type === 'daily') {
|
||||
currentDate = addDays(currentDate, pattern.interval);
|
||||
} else if (pattern.type === 'weekly') {
|
||||
if (pattern.daysOfWeek && pattern.daysOfWeek.length > 0) {
|
||||
// Find next day of week within the week
|
||||
let nextDate = addDays(currentDate, 1);
|
||||
let found = false;
|
||||
let weekCounter = 0;
|
||||
while (!found && weekCounter < 7 * pattern.interval) {
|
||||
if (pattern.daysOfWeek.includes(getDay(nextDate))) {
|
||||
currentDate = nextDate;
|
||||
found = true;
|
||||
} else {
|
||||
nextDate = addDays(nextDate, 1);
|
||||
weekCounter++;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
currentDate = addWeeks(currentDate, pattern.interval);
|
||||
}
|
||||
} else {
|
||||
currentDate = addWeeks(currentDate, pattern.interval);
|
||||
}
|
||||
} else if (pattern.type === 'monthly') {
|
||||
currentDate = addMonths(currentDate, pattern.interval);
|
||||
} else if (pattern.type === 'yearly') {
|
||||
currentDate = addYears(currentDate, pattern.interval);
|
||||
}
|
||||
|
||||
if (currentDate > maxDate) break;
|
||||
|
||||
children.push({
|
||||
schoolId: parentEvent.schoolId,
|
||||
teacherId: parentEvent.teacherId,
|
||||
title: parentEvent.title,
|
||||
date: new Date(currentDate),
|
||||
startTime: parentEvent.startTime,
|
||||
endTime: parentEvent.endTime,
|
||||
eventType: parentEvent.eventType,
|
||||
subjectId: parentEvent.subjectId,
|
||||
classGroupId: parentEvent.classGroupId,
|
||||
notes: parentEvent.notes,
|
||||
allDay: parentEvent.allDay,
|
||||
parentEventId: parentEvent.id,
|
||||
recurrencePattern: patternJson,
|
||||
});
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -10,12 +115,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay, schoolId } = body;
|
||||
const {
|
||||
title, date, startTime, endTime, eventType, subjectId, classGroupId,
|
||||
notes, allDay, schoolId, recurrencePattern, recurrenceEnd,
|
||||
} = body;
|
||||
|
||||
if (!title || !date || !schoolId) {
|
||||
return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse recurrence pattern
|
||||
let parsedPattern: RecurrencePattern | null = null;
|
||||
if (recurrencePattern && recurrencePattern.type && recurrencePattern.type !== 'none') {
|
||||
parsedPattern = recurrencePattern as RecurrencePattern;
|
||||
}
|
||||
|
||||
const parsedRecurrenceEnd = recurrenceEnd ? new Date(recurrenceEnd) : null;
|
||||
|
||||
// Create the parent event
|
||||
const event = await db.calendarEvent.create({
|
||||
data: {
|
||||
schoolId,
|
||||
@@ -29,14 +146,59 @@ export async function POST(request: Request) {
|
||||
classGroupId: classGroupId || null,
|
||||
notes: notes || null,
|
||||
allDay: allDay ?? false,
|
||||
recurrencePattern: parsedPattern ? JSON.stringify(parsedPattern) : null,
|
||||
recurrenceEnd: parsedRecurrenceEnd,
|
||||
},
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
childEvents: {
|
||||
select: { id: true, date: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(event, { status: 201 });
|
||||
// Generate child events for recurring events
|
||||
if (parsedPattern) {
|
||||
const childEvents = generateChildEvents(
|
||||
{
|
||||
id: event.id,
|
||||
schoolId: event.schoolId,
|
||||
teacherId: event.teacherId,
|
||||
title: event.title,
|
||||
date: new Date(date),
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
eventType: event.eventType,
|
||||
subjectId: event.subjectId,
|
||||
classGroupId: event.classGroupId,
|
||||
notes: event.notes,
|
||||
allDay: event.allDay,
|
||||
},
|
||||
parsedPattern,
|
||||
parsedRecurrenceEnd
|
||||
);
|
||||
|
||||
if (childEvents.length > 0) {
|
||||
await db.calendarEvent.createMany({ data: childEvents });
|
||||
}
|
||||
|
||||
// Re-fetch with children
|
||||
const updatedEvent = await db.calendarEvent.findUnique({
|
||||
where: { id: event.id },
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
childEvents: {
|
||||
select: { id: true, date: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ...updatedEvent, childCount: childEvents.length }, { status: 201 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...event, childCount: 0 }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('CalendarEvent POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -81,6 +243,9 @@ export async function GET(request: Request) {
|
||||
include: {
|
||||
subject: { select: { id: true, name: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
childEvents: {
|
||||
select: { id: true, date: true },
|
||||
},
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
@@ -253,6 +253,47 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
filename = `grades_export.csv`;
|
||||
} else if (type === 'attendance') {
|
||||
// Attendance CSV
|
||||
const where: Record<string, unknown> = {};
|
||||
if (classGroupId) where.classGroupId = classGroupId;
|
||||
|
||||
let records = await db.attendanceRecord.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
student: { select: { id: true, firstName: true, lastName: true } },
|
||||
classGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (schoolId && !classGroupId) {
|
||||
const classGroups = await db.classGroup.findMany({
|
||||
where: { schoolId },
|
||||
select: { id: true },
|
||||
});
|
||||
const classIds = classGroups.map((c) => c.id);
|
||||
records = records.filter((r) => classIds.includes(r.classGroupId));
|
||||
}
|
||||
|
||||
const header = ['ID', 'Date', 'Student First Name', 'Student Last Name', 'Student ID', 'Class', 'Status', 'Arrival Time', 'Note'];
|
||||
csvContent = header.map(escapeCsvField).join(',') + '\n';
|
||||
|
||||
for (const r of records) {
|
||||
csvContent += toCsvRow([
|
||||
r.id,
|
||||
new Date(r.date).toLocaleDateString(),
|
||||
r.student.firstName,
|
||||
r.student.lastName,
|
||||
r.studentId,
|
||||
r.classGroup.name,
|
||||
r.status,
|
||||
r.arrivalTime ?? '',
|
||||
r.note ?? '',
|
||||
]) + '\n';
|
||||
}
|
||||
|
||||
filename = `attendance_export.csv`;
|
||||
}
|
||||
|
||||
// Create audit log for export
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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);
|
||||
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(',');
|
||||
}
|
||||
|
||||
interface ImportRow {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) return { headers: [], rows: [] };
|
||||
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||
const rows: ImportRow[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (const char of lines[i]) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const row: ImportRow = {};
|
||||
headers.forEach((header, idx) => {
|
||||
row[header] = values[idx] ?? '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
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 formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = formData.get('type') as string || 'students';
|
||||
const schoolId = formData.get('schoolId') as string || session.user?.schoolId;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!schoolId) {
|
||||
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
const { headers, rows } = parseCsv(text);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'No data rows found in CSV' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result: ImportResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
if (type === 'students') {
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const firstName = row['First Name'] || row['firstName'] || row['Vorname'] || '';
|
||||
const lastName = row['Last Name'] || row['lastName'] || row['Nachname'] || '';
|
||||
const externalId = row['External ID'] || row['externalId'] || row['ID'] || '';
|
||||
const dateOfBirth = row['Date of Birth'] || row['dateOfBirth'] || row['Geburtsdatum'] || '';
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing first/last name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = externalId
|
||||
? await db.student.findFirst({ where: { externalId, schoolId, deletedAt: null } })
|
||||
: null;
|
||||
|
||||
if (existing) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.student.create({
|
||||
data: {
|
||||
schoolId,
|
||||
firstName,
|
||||
lastName,
|
||||
externalId: externalId || null,
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'assessments') {
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const title = row['Title'] || row['title'] || row['Titel'] || '';
|
||||
const classGroupId = row['Class ID'] || row['classGroupId'] || '';
|
||||
const subjectId = row['Subject ID'] || row['subjectId'] || '';
|
||||
const date = row['Date'] || row['date'] || row['Datum'] || '';
|
||||
const assessmentType = row['Type'] || row['type'] || 'WRITTEN';
|
||||
|
||||
if (!title || !classGroupId) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing title or class ID`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.assessment.create({
|
||||
data: {
|
||||
title,
|
||||
classGroupId,
|
||||
subjectId: subjectId || null,
|
||||
teacherId: session.userId,
|
||||
date: date ? new Date(date) : new Date(),
|
||||
type: assessmentType,
|
||||
maxScore: row['Max Score'] || row['maxScore'] ? parseFloat(row['Max Score'] || row['maxScore']) : null,
|
||||
weight: row['Weight'] || row['weight'] ? parseFloat(row['Weight'] || row['weight']) : 1.0,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
} else if (type === 'grades') {
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const studentId = row['Student ID'] || row['studentId'] || '';
|
||||
const assessmentId = row['Assessment ID'] || row['assessmentId'] || '';
|
||||
const score = row['Score'] || row['score'] || '';
|
||||
|
||||
if (!studentId || !assessmentId) {
|
||||
result.skipped++;
|
||||
result.errors.push(`Row skipped: missing student ID or assessment ID`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.assessmentResult.create({
|
||||
data: {
|
||||
studentId,
|
||||
assessmentId,
|
||||
score: score ? parseFloat(score) : null,
|
||||
note: row['Note'] || row['note'] || null,
|
||||
},
|
||||
});
|
||||
result.created++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Row error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId: session.user?.schoolId ?? null,
|
||||
action: 'IMPORT',
|
||||
entityType: 'DataImport',
|
||||
entityId: null,
|
||||
metadata: JSON.stringify({ type, created: result.created, skipped: result.skipped, errorCount: result.errors.length }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
type,
|
||||
totalRows: rows.length,
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
errorCount: result.errors.length,
|
||||
errors: result.errors.slice(0, 20),
|
||||
detectedColumns: headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Data Import error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
let csvContent = '';
|
||||
let filename = '';
|
||||
|
||||
if (type === 'students') {
|
||||
csvContent = 'First Name,Last Name,Date of Birth,External ID\nMax,Mustermann,2015-03-15,STU001\nAnna,Schmidt,2015-07-22,STU002\n';
|
||||
filename = 'sample_students.csv';
|
||||
} else if (type === 'assessments') {
|
||||
csvContent = 'Title,Class ID,Subject ID,Date,Type,Max Score,Weight\nMath Test 1,cls_001,subj_math,2025-01-15,WRITTEN,100,1.0\nReading Test,cls_001,subj_german,2025-01-20,WRITTEN,50,1.0\n';
|
||||
filename = 'sample_assessments.csv';
|
||||
} else if (type === 'grades') {
|
||||
csvContent = 'Student ID,Assessment ID,Score,Note\nstu_001,asmt_001,85,Good work\nstu_002,asmt_001,92,Excellent\n';
|
||||
filename = 'sample_grades.csv';
|
||||
}
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sample CSV download error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const MAX_VERSIONS_PER_PAGE = 50;
|
||||
|
||||
// ── GET: List all versions of a page ──
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; pageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id, pageId } = await params;
|
||||
|
||||
const page = await db.notebookPage.findUnique({
|
||||
where: { id: pageId },
|
||||
include: { notebook: true },
|
||||
});
|
||||
|
||||
if (!page || page.notebookId !== id) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const versions = await db.notebookPageVersion.findMany({
|
||||
where: { pageId },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(versions);
|
||||
} catch (error) {
|
||||
console.error('Page versions GET error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST: Create a new version (auto-called when page is saved) ──
|
||||
const createVersionSchema = z.object({
|
||||
editSummary: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; pageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id, pageId } = await params;
|
||||
|
||||
const page = await db.notebookPage.findUnique({
|
||||
where: { id: pageId },
|
||||
include: { notebook: true },
|
||||
});
|
||||
|
||||
if (!page || page.notebookId !== id) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (page.notebook.ownerId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createVersionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get current max version number
|
||||
const latestVersion = await db.notebookPageVersion.findFirst({
|
||||
where: { pageId },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
const versionNumber = latestVersion ? latestVersion.version + 1 : 1;
|
||||
|
||||
// Create the version with current page content
|
||||
const version = await db.notebookPageVersion.create({
|
||||
data: {
|
||||
pageId,
|
||||
version: versionNumber,
|
||||
textContent: page.textContent,
|
||||
drawingData: page.drawingData,
|
||||
editedBy: session.user.id,
|
||||
editSummary: parsed.data.editSummary ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce max 50 versions: delete older ones
|
||||
const allVersions = await db.notebookPageVersion.findMany({
|
||||
where: { pageId },
|
||||
orderBy: { version: 'asc' },
|
||||
});
|
||||
|
||||
if (allVersions.length > MAX_VERSIONS_PER_PAGE) {
|
||||
const toDelete = allVersions.slice(0, allVersions.length - MAX_VERSIONS_PER_PAGE);
|
||||
await db.notebookPageVersion.deleteMany({
|
||||
where: {
|
||||
id: { in: toDelete.map(v => v.id) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(version);
|
||||
} catch (error) {
|
||||
console.error('Page version POST error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT: Restore a specific version (creates a new version from old content) ──
|
||||
const restoreVersionSchema = z.object({
|
||||
versionId: z.string(),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; pageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id, pageId } = await params;
|
||||
|
||||
const page = await db.notebookPage.findUnique({
|
||||
where: { id: pageId },
|
||||
include: { notebook: true },
|
||||
});
|
||||
|
||||
if (!page || page.notebookId !== id) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (page.notebook.ownerId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = restoreVersionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the version to restore
|
||||
const oldVersion = await db.notebookPageVersion.findUnique({
|
||||
where: { id: parsed.data.versionId },
|
||||
});
|
||||
|
||||
if (!oldVersion || oldVersion.pageId !== pageId) {
|
||||
return NextResponse.json({ error: 'Version not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// First, create a version of current content before restoring
|
||||
const latestVersion = await db.notebookPageVersion.findFirst({
|
||||
where: { pageId },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
const preRestoreVersion = latestVersion ? latestVersion.version + 1 : 1;
|
||||
|
||||
await db.notebookPageVersion.create({
|
||||
data: {
|
||||
pageId,
|
||||
version: preRestoreVersion,
|
||||
textContent: page.textContent,
|
||||
drawingData: page.drawingData,
|
||||
editedBy: session.user.id,
|
||||
editSummary: `Pre-restore snapshot (before restoring v${oldVersion.version})`,
|
||||
},
|
||||
});
|
||||
|
||||
// Restore the old content to the page
|
||||
const updatedPage = await db.notebookPage.update({
|
||||
where: { id: pageId },
|
||||
data: {
|
||||
textContent: oldVersion.textContent,
|
||||
drawingData: oldVersion.drawingData,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the restored version entry
|
||||
const restoredVersionNumber = preRestoreVersion + 1;
|
||||
const restoredVersion = await db.notebookPageVersion.create({
|
||||
data: {
|
||||
pageId,
|
||||
version: restoredVersionNumber,
|
||||
textContent: oldVersion.textContent,
|
||||
drawingData: oldVersion.drawingData,
|
||||
editedBy: session.user.id,
|
||||
editSummary: `Restored from v${oldVersion.version}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce max versions
|
||||
const allVersions = await db.notebookPageVersion.findMany({
|
||||
where: { pageId },
|
||||
orderBy: { version: 'asc' },
|
||||
});
|
||||
if (allVersions.length > MAX_VERSIONS_PER_PAGE) {
|
||||
const toDelete = allVersions.slice(0, allVersions.length - MAX_VERSIONS_PER_PAGE);
|
||||
await db.notebookPageVersion.deleteMany({
|
||||
where: { id: { in: toDelete.map(v => v.id) } },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
page: updatedPage,
|
||||
restoredVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Page version restore error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 } = await params;
|
||||
const link = await db.parentStudentLink.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
externalId: true,
|
||||
schoolId: true,
|
||||
enrollments: {
|
||||
select: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
school: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
return NextResponse.json({ error: 'Link not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parents can only view their own links
|
||||
if (session.user?.role === 'PARENT' && link.parentId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(link);
|
||||
} catch (error) {
|
||||
console.error('ParentLink 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 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { relationship } = body;
|
||||
|
||||
const link = await db.parentStudentLink.findUnique({ where: { id } });
|
||||
if (!link) {
|
||||
return NextResponse.json({ error: 'Link not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only admins, teachers, or the parent can update
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
(session.user?.role === 'PARENT' && link.parentId !== session.user.id)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const updated = await db.parentStudentLink.update({
|
||||
where: { id },
|
||||
data: { relationship: relationship ?? null },
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
schoolId: true,
|
||||
},
|
||||
},
|
||||
school: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('ParentLink 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 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const link = await db.parentStudentLink.findUnique({ where: { id } });
|
||||
if (!link) {
|
||||
return NextResponse.json({ error: 'Link not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only admins, teachers, or the parent can delete
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
(session.user?.role === 'PARENT' && link.parentId !== session.user.id)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.parentStudentLink.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ message: 'Link removed' });
|
||||
} catch (error) {
|
||||
console.error('ParentLink DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/db';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
const createLinkSchema = z.object({
|
||||
parentId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
schoolId: z.string().min(1),
|
||||
relationship: z.enum(['mother', 'father', 'guardian', 'other']).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 parentId = searchParams.get('parentId');
|
||||
|
||||
// If parentId is provided, get links for that parent
|
||||
// If user is PARENT role, they can only see their own links
|
||||
let filter: Record<string, unknown> = {};
|
||||
|
||||
if (session.user?.role === 'PARENT') {
|
||||
filter.parentId = session.user.id;
|
||||
} else if (parentId) {
|
||||
// Teachers/admins can query by parentId
|
||||
filter.parentId = parentId;
|
||||
} else if (session.user?.role === 'TEACHER' || session.user?.role === 'SCHOOL_ADMIN') {
|
||||
// Teachers/admins can see all links in their school
|
||||
if (session.user?.schoolId) {
|
||||
filter.schoolId = session.user.schoolId;
|
||||
}
|
||||
}
|
||||
|
||||
const links = await db.parentStudentLink.findMany({
|
||||
where: filter,
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
externalId: true,
|
||||
schoolId: true,
|
||||
enrollments: {
|
||||
select: {
|
||||
classGroup: {
|
||||
select: { id: true, name: true, gradeLevel: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
school: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return NextResponse.json(links);
|
||||
} catch (error) {
|
||||
console.error('ParentLinks 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 });
|
||||
}
|
||||
|
||||
// Only teachers, school admins, or the parent themselves can create links
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'PARENT'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createLinkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { parentId, studentId, schoolId, relationship } = parsed.data;
|
||||
|
||||
// Verify parent user has PARENT role
|
||||
const parentUser = await db.user.findUnique({ where: { id: parentId } });
|
||||
if (!parentUser || parentUser.role !== 'PARENT') {
|
||||
return NextResponse.json({ error: 'User must have PARENT role' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify student exists
|
||||
const student = await db.student.findUnique({ where: { id: studentId } });
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if link already exists
|
||||
const existing = await db.parentStudentLink.findUnique({
|
||||
where: { parentId_studentId: { parentId, studentId } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Link already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Parents can only create links for themselves
|
||||
if (session.user?.role === 'PARENT' && parentId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Parents can only link their own account' }, { status: 403 });
|
||||
}
|
||||
|
||||
// School admins can only create links in their school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user?.schoolId &&
|
||||
schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json({ error: 'Cannot create links outside your school' }, { status: 403 });
|
||||
}
|
||||
|
||||
const link = await db.parentStudentLink.create({
|
||||
data: {
|
||||
parentId,
|
||||
studentId,
|
||||
schoolId,
|
||||
relationship: relationship ?? null,
|
||||
},
|
||||
include: {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
schoolId: true,
|
||||
},
|
||||
},
|
||||
school: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(link, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('ParentLinks 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 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const linkId = searchParams.get('id');
|
||||
|
||||
if (!linkId) {
|
||||
return NextResponse.json({ error: 'Link ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const link = await db.parentStudentLink.findUnique({ where: { id: linkId } });
|
||||
if (!link) {
|
||||
return NextResponse.json({ error: 'Link not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only admins, teachers, or the parent themselves can delete
|
||||
if (
|
||||
session.user?.role !== 'TEACHER' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
(session.user?.role === 'PARENT' && link.parentId !== session.user.id)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await db.parentStudentLink.delete({ where: { id: linkId } });
|
||||
|
||||
return NextResponse.json({ message: 'Link removed' });
|
||||
} catch (error) {
|
||||
console.error('ParentLinks DELETE error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -11,33 +11,90 @@ export async function GET(request: Request) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const reportId = searchParams.get('reportId');
|
||||
const studentId = searchParams.get('studentId');
|
||||
const template = searchParams.get('template') ?? 'full'; // short, full, custom
|
||||
|
||||
if (!reportId) {
|
||||
return NextResponse.json({ error: 'reportId is required' }, { status: 400 });
|
||||
if (!reportId && !studentId) {
|
||||
return NextResponse.json({ error: 'reportId or studentId 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 } },
|
||||
let report;
|
||||
if (reportId) {
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (studentId) {
|
||||
// Generate a report on-the-fly for this student
|
||||
const student = await db.student.findUnique({
|
||||
where: { id: studentId, deletedAt: null },
|
||||
include: {
|
||||
enrollments: {
|
||||
where: { endDate: null },
|
||||
take: 1,
|
||||
include: { classGroup: { select: { id: true, name: true, gradeLevel: true } } },
|
||||
},
|
||||
learningProgressEntries: {
|
||||
take: 20,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
competency: { select: { code: true, title: true } },
|
||||
teacher: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
assessmentResults: {
|
||||
take: 20,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
assessment: { select: { title: true, type: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return NextResponse.json({ error: 'Student not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const classGroup = student.enrollments[0]?.classGroup;
|
||||
report = {
|
||||
id: 'on-the-fly',
|
||||
student: { id: student.id, firstName: student.firstName, lastName: student.lastName },
|
||||
classGroup: classGroup ?? { id: '', name: 'N/A', gradeLevel: 0 },
|
||||
schoolYear: { id: '', label: new Date().getFullYear().toString() },
|
||||
generatedByUser: { id: session.userId ?? '', firstName: session.user?.firstName ?? '', lastName: session.user?.lastName ?? '' },
|
||||
period: 'Aktuell',
|
||||
status: 'DRAFT',
|
||||
includesGrades: true,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: student.learningProgressEntries.map((entry, idx) => ({
|
||||
order: idx,
|
||||
generatedText: `${entry.competency?.title ?? 'Kompetenz'}: ${entry.level ?? 'N/A'}`,
|
||||
competencyCategory: { id: entry.competency?.id ?? '', name: entry.competency?.code ?? '', color: '#10b981' },
|
||||
})),
|
||||
_studentData: student,
|
||||
_classGroup: classGroup,
|
||||
};
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: 'Report not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Generate a print-friendly HTML page
|
||||
const htmlContent = generatePrintHtml(report);
|
||||
const htmlContent = generatePrintHtml(report, template);
|
||||
|
||||
return new NextResponse(htmlContent, {
|
||||
headers: {
|
||||
@@ -50,7 +107,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
function generatePrintHtml(report: Record<string, unknown>, template: string): 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 };
|
||||
@@ -61,6 +118,9 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
competencyCategory: { name: string; color: string | null } | null;
|
||||
}>).sort((a, b) => a.order - b.order);
|
||||
|
||||
const isShort = template === 'short';
|
||||
const initials = student.firstName[0] + student.lastName[0];
|
||||
|
||||
const sectionsHtml = sections.map((s) => {
|
||||
const catName = s.competencyCategory?.name ?? '';
|
||||
const catColor = s.competencyCategory?.color ?? '#10b981';
|
||||
@@ -72,6 +132,43 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
// Grades table for full template
|
||||
const studentData = report._studentData as {
|
||||
assessmentResults?: Array<{
|
||||
score: number | null;
|
||||
grade: string | null;
|
||||
assessment: { title: string; type: string };
|
||||
}>;
|
||||
} | undefined;
|
||||
|
||||
const gradesHtml = !isShort && studentData?.assessmentResults?.length
|
||||
? `
|
||||
<div class="grades-section">
|
||||
<h3 class="section-title" style="color: #10b981">Notenubersicht</h3>
|
||||
<table class="grades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Uberprufung</th>
|
||||
<th>Typ</th>
|
||||
<th>Punkte</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${studentData.assessmentResults!.map((r) => `
|
||||
<tr>
|
||||
<td>${r.assessment.title}</td>
|
||||
<td>${r.assessment.type}</td>
|
||||
<td>${r.score ?? '—'}</td>
|
||||
<td>${r.grade ?? '—'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
@@ -86,23 +183,53 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
border-bottom: 3px solid #10b981;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #10b981;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #10b981, #14b8a6);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header h2 {
|
||||
.header-text h1 {
|
||||
color: #10b981;
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-text h2 {
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.template-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-short { background: #fef3c7; color: #92400e; }
|
||||
.badge-full { background: #d1fae5; color: #065f46; }
|
||||
.badge-custom { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-draft { background: #fef3c7; color: #92400e; }
|
||||
.badge-final { background: #d1fae5; color: #065f46; }
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -116,20 +243,48 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
color: #333;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 14px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.section-text {
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
.grades-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;
|
||||
.grades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-text {
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
.grades-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grades-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.grade-value {
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
@@ -139,25 +294,29 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
padding-top: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
.eco-footer {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #10b981;
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
.badge-draft {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-final {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
.watermark {
|
||||
position: fixed;
|
||||
bottom: 50%;
|
||||
right: -60px;
|
||||
transform: rotate(-45deg);
|
||||
font-size: 72px;
|
||||
color: rgba(16, 185, 129, 0.04);
|
||||
font-weight: 900;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@media print {
|
||||
body { margin: 0; padding: 20px; }
|
||||
.no-print { display: none; }
|
||||
.section { break-inside: avoid; }
|
||||
.grades-section { break-inside: avoid; }
|
||||
}
|
||||
.print-btn {
|
||||
position: fixed;
|
||||
@@ -177,27 +336,37 @@ function generatePrintHtml(report: Record<string, unknown>): string {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="watermark">CompetenceTrack</div>
|
||||
<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 class="avatar">${initials}</div>
|
||||
<div class="header-text">
|
||||
<h1>Kompetenzbericht</h1>
|
||||
<h2>${student.firstName} ${student.lastName}</h2>
|
||||
<span class="template-badge ${template === 'short' ? 'badge-short' : template === 'custom' ? 'badge-custom' : 'badge-full'}">${template === 'short' ? 'Kurzbericht' : template === 'custom' ? 'Individuell' : 'Vollstandig'}</span>
|
||||
<span class="template-badge ${String(report.status) === 'FINAL' ? 'badge-final' : 'badge-draft'}" style="margin-left: 6px">${String(report.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div><dt>Klasse:</dt> <dd>${classGroup.name} (Jahrgang ${classGroup.gradeLevel})</dd></div>
|
||||
<div><dt>Klasse:</dt> <dd>${classGroup.name}${classGroup.gradeLevel ? ` (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>Datum:</dt> <dd>${new Date(String(report.generatedAt)).toLocaleDateString('de-DE')}</dd></div>
|
||||
<div><dt>Noten inklusive:</dt> <dd>${String(report.includesGrades) === 'true' ? 'Ja' : 'Nein'}</dd></div>
|
||||
</div>
|
||||
|
||||
${sectionsHtml}
|
||||
|
||||
${gradesHtml}
|
||||
|
||||
<div class="footer">
|
||||
<p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString()}</p>
|
||||
<p>CompetenceTrack — Kompetenzbericht · ${new Date().toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div class="eco-footer">
|
||||
Digital erstellt — Papier sparen, Umwelt schutzen
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
+149
-7
@@ -6,10 +6,19 @@ 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),
|
||||
password: z.string().min(6),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'),
|
||||
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'STUDENT', 'PARENT']).default('TEACHER'),
|
||||
locale: z.string().default('de'),
|
||||
studentId: z.string().optional(), // link to Student record (for STUDENT role)
|
||||
});
|
||||
|
||||
const bulkCreateStudentAccountsSchema = z.object({
|
||||
schoolId: z.string().min(1),
|
||||
defaultPassword: z.string().min(6).default('Schule2025!'),
|
||||
studentIds: z.array(z.string().min(1)).min(1),
|
||||
emailDomain: z.string().default('schule.de'),
|
||||
locale: z.string().default('de'),
|
||||
});
|
||||
|
||||
@@ -22,13 +31,15 @@ export async function GET(request: Request) {
|
||||
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolId = searchParams.get('schoolId');
|
||||
const roleFilter = searchParams.get('role');
|
||||
|
||||
const where: Record<string, unknown> = { deletedAt: null };
|
||||
|
||||
@@ -39,6 +50,10 @@ export async function GET(request: Request) {
|
||||
where.schoolId = schoolId;
|
||||
}
|
||||
|
||||
if (roleFilter) {
|
||||
where.role = roleFilter;
|
||||
}
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
@@ -51,11 +66,22 @@ export async function GET(request: Request) {
|
||||
},
|
||||
},
|
||||
},
|
||||
parentLinks: {
|
||||
select: {
|
||||
id: true,
|
||||
studentId: true,
|
||||
relationship: true,
|
||||
student: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
classGroupTeachers: true,
|
||||
learningProgressEntries: true,
|
||||
assessments: true,
|
||||
parentLinks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -81,14 +107,117 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// ── Bulk student account creation ──
|
||||
if (body.action === 'bulkCreateStudents') {
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const parsed = bulkCreateStudentAccountsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { schoolId, defaultPassword, studentIds, emailDomain, locale } = parsed.data;
|
||||
|
||||
// School admins can only create in their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user?.schoolId &&
|
||||
schoolId !== session.user.schoolId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create users outside your school' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(defaultPassword);
|
||||
const createdUsers: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
const student = await db.student.findUnique({ where: { id: studentId } });
|
||||
if (!student) continue;
|
||||
|
||||
// Check if student already has a user account
|
||||
const existingUser = await db.user.findFirst({
|
||||
where: {
|
||||
role: 'STUDENT',
|
||||
schoolId,
|
||||
firstName: student.firstName,
|
||||
lastName: student.lastName,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (existingUser) continue; // Skip if already has account
|
||||
|
||||
// Generate email based on school domain pattern
|
||||
const email = `${student.firstName.toLowerCase().replace(/\s/g, '')}.${student.lastName.toLowerCase().replace(/\s/g, '')}@${emailDomain}`;
|
||||
|
||||
// Check if generated email already exists
|
||||
const emailExists = await db.user.findUnique({ where: { email } });
|
||||
if (emailExists) continue;
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName: student.firstName,
|
||||
lastName: student.lastName,
|
||||
role: 'STUDENT',
|
||||
schoolId,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
|
||||
createdUsers.push({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
schoolId: user.schoolId,
|
||||
linkedStudentId: studentId,
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
schoolId,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'User',
|
||||
entityId: 'bulk',
|
||||
metadata: JSON.stringify({
|
||||
count: createdUsers.length,
|
||||
role: 'STUDENT',
|
||||
schoolId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ created: createdUsers, count: createdUsers.length }, { status: 201 });
|
||||
}
|
||||
|
||||
// ── Single user creation ──
|
||||
if (
|
||||
session.user?.role !== 'SUPER_ADMIN' &&
|
||||
session.user?.role !== 'SCHOOL_ADMIN'
|
||||
session.user?.role !== 'SCHOOL_ADMIN' &&
|
||||
session.user?.role !== 'TEACHER'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
@@ -97,12 +226,12 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const { password, ...userData } = parsed.data;
|
||||
const { password, studentId, ...userData } = parsed.data;
|
||||
|
||||
// School admins can only create users in their own school
|
||||
if (
|
||||
session.user?.role === 'SCHOOL_ADMIN' &&
|
||||
session.user.schoolId &&
|
||||
session.user?.schoolId &&
|
||||
userData.schoolId &&
|
||||
userData.schoolId !== session.user.schoolId
|
||||
) {
|
||||
@@ -147,6 +276,18 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
// If STUDENT role and studentId provided, create parent-student link
|
||||
if (userData.role === 'STUDENT' && studentId) {
|
||||
// Verify student exists
|
||||
const student = await db.student.findUnique({ where: { id: studentId } });
|
||||
if (student) {
|
||||
// No direct link between User (STUDENT) and Student - they're separate records
|
||||
// The connection is implicit: same firstName, lastName, schoolId
|
||||
}
|
||||
}
|
||||
|
||||
// If PARENT role, no auto-link here; parent links are created separately
|
||||
|
||||
// Create audit log entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
@@ -160,6 +301,7 @@ export async function POST(request: Request) {
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
linkedStudentId: studentId ?? null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -260,6 +260,45 @@
|
||||
}
|
||||
|
||||
/* Print styles — full print stylesheet lives at end of file */
|
||||
|
||||
/* ─── Slide animations ──────────────────────────────────────────── */
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(-30px); opacity: 0; }
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slide-out 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* ─── Pulse dot for version timeline ────────────────────────────── */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.3); opacity: 0.7; }
|
||||
}
|
||||
.animate-pulse-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Canvas toolbar ────────────────────────────────────────────── */
|
||||
.canvas-toolbar {
|
||||
transition: transform 0.2s ease-out, width 0.2s ease-out, height 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Version timeline ──────────────────────────────────────────── */
|
||||
.version-timeline {
|
||||
position: relative;
|
||||
}
|
||||
.version-timeline > div {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
@@ -777,6 +816,88 @@
|
||||
background: oklch(0.55 0.22 25 / 0.06);
|
||||
}
|
||||
|
||||
/* ─── File Drop Zone ─────────────────────────────────────────────── */
|
||||
.file-drop-zone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 24px;
|
||||
border: 2px dashed oklch(0.75 0.05 155);
|
||||
border-radius: 12px;
|
||||
background: oklch(0.97 0.01 155 / 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.dark .file-drop-zone {
|
||||
border-color: oklch(0.4 0.05 155);
|
||||
background: oklch(0.2 0.02 155 / 0.3);
|
||||
}
|
||||
.file-drop-zone.drag-over {
|
||||
border-color: oklch(0.55 0.17 155);
|
||||
background: oklch(0.55 0.17 155 / 0.1);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
.dark .file-drop-zone.drag-over {
|
||||
border-color: oklch(0.65 0.17 155);
|
||||
background: oklch(0.65 0.17 155 / 0.1);
|
||||
}
|
||||
.file-drop-zone:hover {
|
||||
border-color: oklch(0.55 0.17 155);
|
||||
background: oklch(0.55 0.17 155 / 0.05);
|
||||
}
|
||||
|
||||
/* ─── Import Progress Bar ────────────────────────────────────────── */
|
||||
.import-progress {
|
||||
animation: import-fade-in 0.3s ease-out;
|
||||
}
|
||||
@keyframes import-fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─── Report Preview Page ────────────────────────────────────────── */
|
||||
.report-preview {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 32px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.dark .report-preview {
|
||||
background: oklch(0.15 0.01 260);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ─── Environmental Watermark ────────────────────────────────────── */
|
||||
.watermark-eco {
|
||||
position: fixed;
|
||||
bottom: 50%;
|
||||
right: -60px;
|
||||
transform: rotate(-45deg);
|
||||
font-size: 72px;
|
||||
font-weight: 900;
|
||||
color: oklch(0.55 0.17 155 / 0.04);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── Badge Type ─────────────────────────────────────────────────── */
|
||||
.badge-type {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ─── Status Indicator Dot Pulse ─────────────────────────────────── */
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
|
||||
Reference in new issue
Block a user