import prisma from "../config/database"; import { Prisma } from "@prisma/client"; import bcrypt from "bcryptjs"; import { config } from "../config/env"; const ALLOWED_SORT_FIELDS = [ "id", "username", "email", "first_name", "last_name", "created_at", ]; const USER_SELECT = { id: true, username: true, email: true, first_name: true, last_name: true, role_id: true, is_active: true, last_login: true, totp_enabled: true, created_at: true, updated_at: true, roles: { select: { id: true, name: true, display_name: true } }, } as const; export interface ListUsersParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; permission?: string; } export interface CreateUserData { username: string; email: string; password: string; first_name: string; last_name: string; role_id?: number | null; is_active?: boolean; } export interface UpdateUserData { username?: string; email?: string; password?: string; first_name?: string; last_name?: string; role_id?: number | null; is_active?: boolean | number | string; } export async function listUsers(params: ListUsersParams) { const { page, limit, skip, sort, order, search, permission } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where: Prisma.usersWhereInput = search ? { OR: [ { username: { contains: search } }, { email: { contains: search } }, { first_name: { contains: search } }, { last_name: { contains: search } }, ], } : {}; if (permission) { where.roles = { role_permissions: { some: { permissions: { name: permission } }, }, }; } const [users, total] = await Promise.all([ prisma.users.findMany({ where, skip, take: limit, orderBy: { [sortField]: order }, select: USER_SELECT, }), prisma.users.count({ where }), ]); return { users, total, page, limit }; } export async function getUser(id: number) { return prisma.users.findUnique({ where: { id }, select: USER_SELECT, }); } export async function createUser( data: CreateUserData, callerRoleName?: string, ) { const username = data.username.trim(); const email = data.email.trim(); const firstName = data.first_name.trim(); const lastName = data.last_name.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return { error: "Neplatný formát e-mailu", status: 400 } as const; } // Enforce min password length in-service (consistent with updateUser), // independent of any schema-level check. if (data.password.length < 8) { return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const; } if (data.role_id) { const targetRole = await prisma.roles.findUnique({ where: { id: Number(data.role_id) }, }); if (targetRole?.name === "admin" && callerRoleName !== "admin") { return { error: "Nelze přiřadit roli admin", status: 403 } as const; } } const passwordHash = await bcrypt.hash( data.password, config.security.bcryptCost, ); try { // Run the uniqueness checks INSIDE the transaction with the create so two // concurrent creates of the same username/email return the intended 409 // instead of letting the DB unique constraint throw an unhandled 500 // (mirrors updateUser). const user = await prisma.$transaction(async (tx) => { const existingUsername = await tx.users.findFirst({ where: { username }, }); if (existingUsername) { throw Object.assign(new Error("Uživatelské jméno již existuje"), { status: 409, }); } const existingEmail = await tx.users.findFirst({ where: { email } }); if (existingEmail) { throw Object.assign(new Error("E-mail již existuje"), { status: 409 }); } return tx.users.create({ data: { username, email, password_hash: passwordHash, first_name: firstName, last_name: lastName, role_id: data.role_id ? Number(data.role_id) : null, is_active: data.is_active !== false, }, }); }); return { user } as const; } catch (err) { if (err instanceof Error && "status" in err) { return { error: err.message, status: (err as Error & { status: number }).status, } as const; } // A concurrent insert that beat the in-transaction uniqueness check surfaces // as a P2002 unique-constraint violation — return the intended 409, not 500. if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" ) { return { error: "Uživatelské jméno nebo e-mail již existuje", status: 409, } as const; } throw err; } } export async function updateUser( id: number, body: UpdateUserData, callerRoleName?: string, ) { const existing = await prisma.users.findUnique({ where: { id } }); if (!existing) { return { error: "Uživatel nenalezen", status: 404 } as const; } if (body.role_id !== undefined) { if (body.role_id) { const targetRole = await prisma.roles.findUnique({ where: { id: Number(body.role_id) }, }); if (targetRole?.name === "admin" && callerRoleName !== "admin") { return { error: "Nelze přiřadit roli admin", status: 403 } as const; } } if ( existing.role_id !== null && Number(body.role_id) !== existing.role_id ) { const currentRole = await prisma.roles.findUnique({ where: { id: existing.role_id }, }); if (currentRole?.name === "admin") { const adminRole = await prisma.roles.findFirst({ where: { name: "admin" }, }); if (adminRole) { const activeAdminCount = await prisma.users.count({ where: { role_id: adminRole.id, is_active: true }, }); if (activeAdminCount <= 1) { return { error: "Nelze odebrat roli poslednímu aktivnímu administrátorovi", status: 400, } as const; } } } } } if (body.is_active !== undefined && !body.is_active) { if (existing.role_id !== null && existing.is_active) { const adminRole = await prisma.roles.findFirst({ where: { name: "admin" }, }); if (adminRole && existing.role_id === adminRole.id) { const activeAdminCount = await prisma.users.count({ where: { role_id: adminRole.id, is_active: true }, }); if (activeAdminCount <= 1) { return { error: "Nelze deaktivovat posledního aktivního administrátora", status: 400, } as const; } } } } const data: Record = {}; try { await prisma.$transaction(async (tx) => { if (body.username !== undefined) { const newUsername = String(body.username).trim(); if (newUsername !== existing.username) { const existingUsername = await tx.users.findFirst({ where: { username: newUsername }, }); if (existingUsername) { throw Object.assign(new Error("Uživatelské jméno již existuje"), { status: 409, }); } } data.username = newUsername; } if (body.email !== undefined) { const newEmail = String(body.email).trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { throw Object.assign(new Error("Neplatný formát e-mailu"), { status: 400, }); } const existingEmail = await tx.users.findFirst({ where: { email: newEmail, id: { not: id } }, }); if (existingEmail) { throw Object.assign(new Error("E-mail již existuje"), { status: 409, }); } data.email = newEmail; } if (body.first_name !== undefined) data.first_name = String(body.first_name); if (body.last_name !== undefined) data.last_name = String(body.last_name); if (body.role_id !== undefined) data.role_id = body.role_id ? Number(body.role_id) : null; if (body.is_active !== undefined) data.is_active = body.is_active === true || body.is_active === 1 || body.is_active === "1"; if (body.password) { const newPassword = String(body.password); if (newPassword.length < 8) { throw Object.assign(new Error("Heslo musí mít alespoň 8 znaků"), { status: 400, }); } data.password_hash = await bcrypt.hash( newPassword, config.security.bcryptCost, ); data.password_changed_at = new Date(); } await tx.users.update({ where: { id }, data }); }); } catch (err) { if (err instanceof Error && "status" in err) { return { error: err.message, status: (err as Error & { status: number }).status, } as const; } throw err; } return { id, username: existing.username } as const; } export async function deleteUser(id: number, currentUserId?: number) { if (id === currentUserId) { return { error: "Nelze smazat vlastní účet", status: 400 } as const; } const existing = await prisma.users.findUnique({ where: { id } }); if (!existing) { return { error: "Uživatel nenalezen", status: 404 } as const; } await prisma.$transaction(async (tx) => { await tx.refresh_tokens.deleteMany({ where: { user_id: id } }); await tx.users.delete({ where: { id } }); }); return { id, username: existing.username } as const; }