feat: Round 24 - Klassenlehrer management, vice principal, exam calendar, student portal exams

- Klassenlehrer management: Assign/change responsible teacher, privilege badges
- Vice principal management: Assign/remove VP role, admins list
- Calendar exam integration: Exam countdown, print calendar, exam filter
- Student portal: Exams tab with countdown, AI study tips, virtual character
- Dashboard: Klassenlehrer section with pending illness/communications
- API: PUT /api/users/role for role management
- API: Enhanced classes GET to include responsibleTeacher
- 90+ new i18n keys
This commit is contained in:
Mustafa Cetin committed 2026-07-29 21:13:32 +00:00
1 parent 44ff170fca
commit f2caf267c7
12 files changed
+1522 -14

No files matched your search

+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 updateClassSchema = z.object({
name: z.string().min(1).optional(),
gradeLevel: z.number().int().min(1).max(13).optional(),
schoolType: z
.enum(['ELEMENTARY', 'MIDDLE', 'GYMNASIUM', 'OTHER'])
.optional(),
responsibleTeacherId: z.string().nullable().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 classGroup = await db.classGroup.findUnique({
where: { id },
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
responsibleTeacher: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
teachers: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
},
_count: {
select: {
enrollments: {
where: { endDate: null },
},
assessments: true,
},
},
},
});
if (!classGroup) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
const result = {
...classGroup,
studentCount: classGroup._count.enrollments,
assessmentCount: classGroup._count.assessments,
teacherList: classGroup.teachers.map((t) => ({
...t.user,
teacherRole: t.role,
})),
};
return NextResponse.json(result);
} catch (error) {
console.error('Class 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 = updateClassSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const existing = await db.classGroup.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
// If updating responsibleTeacherId, validate the teacher exists
if (parsed.data.responsibleTeacherId !== undefined) {
const teacherId = parsed.data.responsibleTeacherId;
if (teacherId) {
const teacher = await db.user.findUnique({
where: { id: teacherId },
select: { id: true, role: true },
});
if (!teacher) {
return NextResponse.json(
{ error: 'Teacher not found' },
{ status: 404 }
);
}
if (teacher.role !== 'TEACHER' && teacher.role !== 'SCHOOL_ADMIN' && teacher.role !== 'VICE_PRINCIPAL' && teacher.role !== 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'User is not a teacher' },
{ status: 400 }
);
}
}
}
const classGroup = await db.classGroup.update({
where: { id },
data: parsed.data,
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
responsibleTeacher: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
teachers: {
include: {
user: {
select: { id: true, firstName: true, lastName: true, email: true },
},
},
},
_count: {
select: {
enrollments: {
where: { endDate: null },
},
},
},
},
});
// Audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: classGroup.schoolId,
action: 'UPDATE',
entityType: 'ClassGroup',
entityId: id,
metadata: JSON.stringify(parsed.data),
},
});
const result = {
...classGroup,
studentCount: classGroup._count.enrollments,
teacherList: classGroup.teachers.map((t) => ({
...t.user,
teacherRole: t.role,
})),
};
return NextResponse.json(result);
} catch (error) {
console.error('Class 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;
const existing = await db.classGroup.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: 'Class not found' }, { status: 404 });
}
await db.classGroup.delete({ where: { id } });
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'DELETE',
entityType: 'ClassGroup',
entityId: id,
metadata: JSON.stringify({ name: existing.name }),
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Class DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+10
View File
@@ -40,6 +40,14 @@ export async function GET(request: Request) {
include: {
school: { select: { id: true, name: true } },
schoolYear: { select: { id: true, label: true } },
responsibleTeacher: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
teachers: {
include: {
user: {
@@ -57,6 +65,7 @@ export async function GET(request: Request) {
enrollments: {
where: { endDate: null },
},
assessments: true,
},
},
},
@@ -65,6 +74,7 @@ export async function GET(request: Request) {
const result = classes.map((cls) => ({
...cls,
studentCount: cls._count.enrollments,
assessmentCount: cls._count.assessments,
teacherList: cls.teachers.map((t) => ({
...t.user,
teacherRole: t.role,
+1 -1
View File
@@ -7,7 +7,7 @@ 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(),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN', 'VICE_PRINCIPAL']).optional(),
locale: z.string().optional(),
});
+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 changeRoleSchema = z.object({
userId: z.string().min(1),
role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'VICE_PRINCIPAL']),
});
export async function PUT(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Only admins can change roles
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 = changeRoleSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { userId, role } = parsed.data;
// Cannot change own role
if (userId === session.userId) {
return NextResponse.json(
{ error: 'Cannot change your own role' },
{ status: 400 }
);
}
const existing = await db.user.findUnique({ where: { id: userId } });
if (!existing) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// School admins can only manage 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 assign SUPER_ADMIN role
if (session.user?.role === 'SCHOOL_ADMIN' && role === 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'Cannot assign super admin role' },
{ status: 403 }
);
}
// Cannot change role of a SUPER_ADMIN unless you are one
if (existing.role === 'SUPER_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'Cannot change super admin role' },
{ status: 403 }
);
}
const user = await db.user.update({
where: { id: userId },
data: { role },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
schoolId: true,
},
});
// Audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId: existing.schoolId,
action: 'ROLE_CHANGE',
entityType: 'User',
entityId: userId,
metadata: JSON.stringify({
previousRole: existing.role,
newRole: role,
}),
},
});
return NextResponse.json(user);
} catch (error) {
console.error('User role PUT error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}