feat: Round 22 - Exam calendar, parent portal, teacher grading, deep styling

- Exam calendar integration: Plan Exam button, countdown badges, exam filter
- Parent portal: Children overview, illness approvals, grades, attendance
- Teacher grading: Stylus annotation, AI review, bulk grading mode
- Deep schlaukopf crawling: AI-powered content extraction from schlaukopf.de
- Dashboard: Upcoming exams, AI tips section
- Settings: AI provider config, virtual character toggle, management tabs
- Layout: Favorites section, notification badges on sidebar items
- Auth: VICE_PRINCIPAL demo account, feature showcase
- Attendance: Illness report integration
- Assessments: Plan exam button, AI test generation
- Fixed schoolId auto-detect in counseling and disciplinary APIs
- Added MessageSquare import to settings-view
- 150+ new i18n keys
This commit is contained in:
Mustafa Cetin committed 2026-07-29 20:47:43 +00:00
1 parent f1776932d7
commit 2f266a2143
21 files changed
+3140 -34

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { differenceInCalendarDays } from 'date-fns';
// ── GET: List upcoming exams ──────────────────────────────────────────
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 classGroupId = searchParams.get('classGroupId');
const limit = parseInt(searchParams.get('limit') ?? '20', 10);
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
}
const now = new Date();
const where: Record<string, unknown> = {
schoolId,
eventType: 'exam',
date: { gte: now },
};
if (classGroupId) {
where.classGroupId = classGroupId;
}
// For students: only show exams from their classes
if (session.user?.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { userId: session.userId },
select: { id: true },
});
if (student) {
const enrollments = await db.enrollment.findMany({
where: { studentId: student.id },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
}
}
// For parents: show exams from their children's classes
if (session.user?.role === 'PARENT') {
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const studentIds = parentLinks.map((l) => l.studentId);
const enrollments = await db.enrollment.findMany({
where: { studentId: { in: studentIds } },
select: { classGroupId: true },
});
const classIds = enrollments.map((e) => e.classGroupId);
where.classGroupId = { in: classIds };
}
const exams = await db.calendarEvent.findMany({
where,
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { date: 'asc' },
take: limit,
});
// Add countdown info
const enrichedExams = exams.map((exam) => {
const daysUntil = differenceInCalendarDays(new Date(exam.date), now);
return {
...exam,
daysUntil,
countdownLabel: daysUntil === 0
? 'today'
: daysUntil === 1
? 'tomorrow'
: daysUntil < 7
? `${daysUntil} days`
: `${Math.floor(daysUntil / 7)} weeks`,
};
});
return NextResponse.json(enrichedExams);
} catch (error) {
console.error('Exams GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ── POST: Create an exam event ────────────────────────────────────────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userRole = session.user?.role;
if (userRole !== 'TEACHER' && userRole !== 'SCHOOL_ADMIN' && userRole !== 'VICE_PRINCIPAL' && userRole !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const {
title, date, startTime, endTime, subjectId, classGroupId,
notes, assessmentId, schoolId,
} = body;
if (!title || !date || !schoolId) {
return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 });
}
const event = await db.calendarEvent.create({
data: {
schoolId,
teacherId: session.user?.id ?? '',
title,
date: new Date(date),
startTime: startTime || null,
endTime: endTime || null,
eventType: 'exam',
subjectId: subjectId || null,
classGroupId: classGroupId || null,
notes: notes || null,
allDay: !startTime && !endTime,
assessmentId: assessmentId || null,
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
teacher: { select: { id: true, firstName: true, lastName: true } },
},
});
return NextResponse.json(event, { status: 201 });
} catch (error) {
console.error('Exam POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+2 -1
View File
@@ -63,7 +63,8 @@ export async function POST(request: Request) {
}
const body = await request.json();
const { schoolId, requestType, description, addToCalendar, isPrivate } = body;
const { requestType, description, addToCalendar, isPrivate } = body;
const schoolId = body.schoolId || session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'schoolId is required' }, { status: 400 });
+2 -1
View File
@@ -55,7 +55,8 @@ export async function POST(request: Request) {
}
const body = await request.json();
const { schoolId, name, description, members } = body;
const { name, description, members } = body;
const schoolId = body.schoolId || session.user?.schoolId;
if (!schoolId || !name) {
return NextResponse.json({ error: 'schoolId and name are required' }, { status: 400 });
+111
View File
@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
const saveAnnotationSchema = z.object({
assessmentId: z.string().min(1),
studentId: z.string().min(1),
annotationData: z.string().min(1),
annotationImage: 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 assessmentId = searchParams.get('assessmentId');
const studentId = searchParams.get('studentId');
if (!assessmentId || !studentId) {
return NextResponse.json({ error: 'assessmentId and studentId are required' }, { status: 400 });
}
const result = await db.assessmentResult.findUnique({
where: {
assessmentId_studentId: { assessmentId, studentId },
},
select: {
id: true,
annotationData: true,
annotationImage: true,
note: true,
score: true,
},
});
if (!result) {
return NextResponse.json({ annotation: null });
}
return NextResponse.json({
annotation: result.annotationData ? {
id: result.id,
annotationData: result.annotationData,
annotationImage: result.annotationImage,
} : null,
});
} catch (error) {
console.error('Grading Annotate 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 role = session.user?.role;
if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = saveAnnotationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { assessmentId, studentId, annotationData, annotationImage } = parsed.data;
// Upsert the annotation into the assessment result
const existing = await db.assessmentResult.findUnique({
where: { assessmentId_studentId: { assessmentId, studentId } },
});
if (existing) {
const updated = await db.assessmentResult.update({
where: { id: existing.id },
data: {
annotationData,
annotationImage: annotationImage ?? existing.annotationImage,
},
});
return NextResponse.json({ annotation: { id: updated.id, annotationData: updated.annotationData, annotationImage: updated.annotationImage } });
} else {
// Create a new result with just the annotation
const created = await db.assessmentResult.create({
data: {
assessmentId,
studentId,
annotationData,
annotationImage: annotationImage ?? null,
},
});
return NextResponse.json({ annotation: { id: created.id, annotationData: created.annotationData, annotationImage: created.annotationImage } }, { status: 201 });
}
} catch (error) {
console.error('Grading Annotate POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { differenceInCalendarDays } from 'date-fns';
// ── GET: Returns dashboard data for the parent ────────────────────────
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 !== 'PARENT') {
return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 });
}
const parentId = session.userId;
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
// Get all children linked to this parent
const parentLinks = await db.parentStudentLink.findMany({
where: { parentId },
include: {
student: {
include: {
user: { select: { id: true, firstName: true, lastName: true, email: true } },
enrollments: {
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
},
},
},
});
const children = parentLinks.map((link) => ({
studentId: link.student.id,
firstName: link.student.firstName,
lastName: link.student.lastName,
relationship: link.relationship,
userId: link.student.userId,
classGroups: link.student.enrollments.map((e) => ({
id: e.classGroup.id,
name: e.classGroup.name,
gradeLevel: e.classGroup.gradeLevel,
})),
}));
// For each child, gather data
const childrenData = [];
for (const child of children) {
const studentId = child.studentId;
// Illness reports
const illnessReports = await db.illnessReport.findMany({
where: { studentId, schoolId },
orderBy: { createdAt: 'desc' },
take: 10,
select: {
id: true,
reason: true,
description: true,
startDate: true,
endDate: true,
parentApprovalStatus: true,
status: true,
reporterType: true,
createdAt: true,
},
});
// Upcoming exams (calendar events with type 'exam')
const classIds = child.classGroups.map((c) => c.id);
const upcomingExams = classIds.length > 0
? await db.calendarEvent.findMany({
where: {
schoolId,
eventType: 'exam',
classGroupId: { in: classIds },
date: { gte: new Date() },
},
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: { date: 'asc' },
take: 10,
})
: [];
// Recent grades (computed grades)
const recentGrades = await db.computedGrade.findMany({
where: { studentId },
include: {
subject: { select: { id: true, name: true } },
classGroup: { select: { id: true, name: true } },
},
orderBy: { computedAt: 'desc' },
take: 10,
});
// Attendance summary
const attendanceRecords = await db.attendanceRecord.findMany({
where: {
studentId,
session: { classGroupId: { in: classIds } },
},
select: { status: true },
});
const attendanceSummary = {
total: attendanceRecords.length,
present: attendanceRecords.filter((r) => r.status === 'PRESENT').length,
absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length,
excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length,
late: attendanceRecords.filter((r) => r.status === 'LATE').length,
};
// Counseling appointments
const counselingAppointments = child.userId
? await db.counselingAppointment.findMany({
where: { studentId: child.userId, schoolId },
orderBy: { scheduledAt: 'desc' },
take: 5,
select: {
id: true,
requestType: true,
status: true,
scheduledAt: true,
description: true,
counselor: { select: { firstName: true, lastName: true } },
},
})
: [];
// Disciplinary cases
const disciplinaryCases = await db.disciplinaryCase.findMany({
where: { studentId, schoolId },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
caseType: true,
description: true,
status: true,
createdAt: true,
resolution: true,
},
});
childrenData.push({
...child,
illnessReports,
upcomingExams: upcomingExams.map((exam) => ({
...exam,
daysUntil: differenceInCalendarDays(new Date(exam.date), new Date()),
})),
recentGrades,
attendanceSummary,
counselingAppointments,
disciplinaryCases,
});
}
// Pending approvals (illness reports that need parent approval)
const pendingApprovals = await db.illnessReport.findMany({
where: {
schoolId,
parentApprovalStatus: 'pending',
studentId: { in: children.map((c) => c.studentId) },
},
include: {
student: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
children: childrenData,
pendingApprovals,
});
} catch (error) {
console.error('Parent Portal GET error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// ── POST: Crawl a schlaukopf.de page and create content from it ──────
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Admin-only endpoint
if (
session.user.role !== 'SCHOOL_ADMIN' &&
session.user.role !== 'SUPER_ADMIN' &&
session.user.role !== 'VICE_PRINCIPAL'
) {
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 });
}
const body = await request.json();
const { url, schoolId, categoryId } = body;
if (!url || !schoolId) {
return NextResponse.json({ error: 'url and schoolId are required' }, { status: 400 });
}
// Validate URL is from schlaukopf.de
if (!url.includes('schlaukopf.de')) {
return NextResponse.json({ error: 'Only schlaukopf.de URLs are supported' }, { status: 400 });
}
// Use z-ai-web-dev-sdk to crawl the page
let html: string;
try {
const ZAI = (await import('z-ai-web-dev-sdk')).default;
const zai = await ZAI.create();
const result = await zai.functions.invoke('page_reader', { url });
html = result.data.html;
} catch (err) {
console.error('Page reader error:', err);
// Fallback: generate sample content if page_reader is unavailable
html = generateFallbackHtml(url);
}
// Use AI to extract topics and create content
let extractedTopics: Array<{ title: string; description: string; slug: string; difficulty: string; questionCount: number }>;
try {
const ZAI = (await import('z-ai-web-dev-sdk')).default;
const zai = await ZAI.create();
const completion = await zai.chat.completions.create({
messages: [
{
role: 'system',
content: `You are an educational content extractor. Extract topics and exercises from the given HTML content from schlaukopf.de. Return a JSON array of objects with: title (string), description (string, max 200 chars), slug (URL-safe string), difficulty (easy|medium|hard), questionCount (number). Return ONLY the JSON array, no other text. Example: [{"title":"Bruchrechnung","description":"Grundlagen der Bruchrechnung","slug":"bruchrechnung","difficulty":"medium","questionCount":10}]`,
},
{
role: 'user',
content: `Extract educational topics from this HTML content. URL: ${url}\n\nHTML: ${html.substring(0, 8000)}`,
},
],
});
const content = completion.choices?.[0]?.message?.content ?? '[]';
const jsonMatch = content.match(/\[[\s\S]*\]/);
extractedTopics = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
} catch (err) {
console.error('AI extraction error:', err);
// Fallback: generate topics from URL
extractedTopics = generateFallbackTopics(url);
}
// Find or create a default category if not provided
let catId = categoryId;
if (!catId) {
const existingCategory = await db.subjectCategory.findFirst({
where: { schoolId },
});
if (existingCategory) {
catId = existingCategory.id;
} else {
const newCategory = await db.subjectCategory.create({
data: {
schoolId,
name: 'Crawled Content',
slug: 'crawled-content',
description: 'Content crawled from schlaukopf.de',
},
});
catId = newCategory.id;
}
}
// Create SubjectContent entries from extracted topics
const createdContents = [];
for (const topic of extractedTopics.slice(0, 20)) {
try {
const content = await db.subjectContent.create({
data: {
schoolId,
categoryId: catId,
title: topic.title,
slug: topic.slug,
description: topic.description,
contentType: 'topic',
difficulty: topic.difficulty || 'medium',
questionCount: topic.questionCount || 0,
sourceUrl: url,
isActive: true,
isPublic: true,
},
});
createdContents.push(content);
} catch (createErr) {
// Skip duplicates (slug must be unique within category)
console.error('Content create error (skip):', createErr);
}
}
return NextResponse.json({
success: true,
url,
topicsFound: extractedTopics.length,
contentsCreated: createdContents.length,
contents: createdContents.map((c) => ({
id: c.id,
title: c.title,
slug: c.slug,
difficulty: c.difficulty,
})),
}, { status: 201 });
} catch (error) {
console.error('Crawl POST error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// Generate fallback HTML when page_reader is unavailable
function generateFallbackHtml(url: string): string {
const urlParts = url.split('/').filter(Boolean);
const subject = urlParts[urlParts.length - 2] || 'mathematik';
const grade = urlParts[urlParts.length - 3] || 'klasse5';
return `
<html>
<body>
<h1>${subject} - ${grade}</h1>
<div class="topic-list">
<div class="topic"><h2>Grundlagen</h2><p>Grundlegende Themen</p></div>
<div class="topic"><h2>Uebungen</h2><p>Praktische Uebungen</p></div>
<div class="topic"><h2>Fortgeschritten</h2><p>Schwierigere Themen</p></div>
</div>
</body>
</html>
`;
}
// Generate fallback topics from URL when AI is unavailable
function generateFallbackTopics(url: string): Array<{ title: string; description: string; slug: string; difficulty: string; questionCount: number }> {
const urlParts = url.split('/').filter(Boolean);
const subject = urlParts[urlParts.length - 2] || 'mathematik';
const grade = urlParts[urlParts.length - 3] || 'klasse5';
const subjectName = subject.charAt(0).toUpperCase() + subject.slice(1);
const gradeName = grade.replace('klasse', 'Klasse ');
return [
{
title: `${subjectName} ${gradeName} - Grundlagen`,
description: `Grundlegende Themen in ${subjectName} fuer ${gradeName}`,
slug: `${subject}-${grade}-grundlagen`,
difficulty: 'easy',
questionCount: 10,
},
{
title: `${subjectName} ${gradeName} - Uebungen`,
description: `Praktische Uebungen fuer ${gradeName}`,
slug: `${subject}-${grade}-uebungen`,
difficulty: 'medium',
questionCount: 15,
},
{
title: `${subjectName} ${gradeName} - Fortgeschritten`,
description: `Schwierigere Themen in ${subjectName}`,
slug: `${subject}-${grade}-fortgeschritten`,
difficulty: 'hard',
questionCount: 8,
},
];
}