Files
schulos/src/app/api/users/route.ts
T
2026-08-02 14:00:21 +02:00

315 lines
9.3 KiB
TypeScript
Executable File

import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getSession, hashPassword } from '@/lib/auth';
import { isAdministrator } from '@/lib/role-access';
const createUserSchema = z.object({
schoolId: z.string().nullable().optional(),
email: z.string().email().min(1),
password: z.string().min(6),
firstName: z.string().min(1),
lastName: z.string().min(1),
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'),
});
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const schoolId = searchParams.get('schoolId');
const roleFilter = searchParams.get('role');
const where: Record<string, unknown> = { deletedAt: null };
// Non-super admins can only see users from their own school
if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId) {
where.schoolId = session.user.schoolId;
} else if (schoolId) {
where.schoolId = schoolId;
}
if (roleFilter) {
where.role = roleFilter;
}
const users = await db.user.findMany({
where,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
include: {
school: { select: { id: true, name: true } },
classGroupTeachers: {
include: {
classGroup: {
select: { id: true, name: true, gradeLevel: true },
},
},
},
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,
},
},
},
});
// Strip password hashes before returning
const safeUsers = users.map(({ passwordHash: _stripped, ...rest }) => rest);
return NextResponse.json(safeUsers);
} catch (error) {
console.error('Users GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// ── Bulk student account creation ──
if (body.action === 'bulkCreateStudents') {
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.role !== 'SCHOOL_ADMIN'
) {
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'
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.issues },
{ status: 400 }
);
}
const { password, studentId, ...userData } = parsed.data;
// Administrators can only create users in their own school.
if (
session.user?.role === 'SCHOOL_ADMIN' &&
session.user?.schoolId &&
userData.schoolId &&
userData.schoolId !== session.user.schoolId
) {
return NextResponse.json(
{ error: 'Cannot create users outside your school' },
{ status: 403 }
);
}
// School admins cannot create super admin accounts
if (session.user?.role === 'SCHOOL_ADMIN' && userData.role === 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'Cannot create super admin accounts' },
{ status: 403 }
);
}
// Check email uniqueness
const existingEmail = await db.user.findUnique({
where: { email: userData.email },
});
if (existingEmail) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
const passwordHash = await hashPassword(password);
const user = await db.user.create({
data: {
...userData,
passwordHash,
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
},
include: {
school: { select: { id: true, name: true } },
},
});
// 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: {
userId: session.userId,
schoolId: userData.schoolId ?? session.user?.schoolId ?? null,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
metadata: JSON.stringify({
email: user.email,
role: user.role,
firstName: user.firstName,
lastName: user.lastName,
linkedStudentId: studentId ?? null,
}),
},
});
// Strip password hash from response
const { passwordHash: _stripped, ...safeUser } = user as Record<string, unknown>;
return NextResponse.json(safeUser, { status: 201 });
} catch (error) {
console.error('Users POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}