From dbf8749b37618ee729e01d9393c5da0f5b017a45 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 09:03:31 +0100 Subject: [PATCH] refactor: extract users business logic into users.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/users.ts | 168 +++++------------------------- src/services/users.service.ts | 186 ++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 143 deletions(-) create mode 100644 src/services/users.service.ts diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index 0c831ed..b0b91da 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -1,53 +1,22 @@ import { FastifyInstance } from 'fastify'; -import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; -import bcrypt from 'bcryptjs'; -import { config } from '../../config/env'; import { parseBody } from '../../schemas/common'; import { CreateUserSchema, UpdateUserSchema } from '../../schemas/users.schema'; - -const ALLOWED_SORT_FIELDS = ['id', 'username', 'email', 'first_name', 'last_name', 'created_at']; +import { listUsers, getUser, createUser, updateUser, deleteUser } from '../../services/users.service'; export default async function usersRoutes(fastify: FastifyInstance): Promise { // GET /api/admin/users fastify.get('/', { preHandler: requirePermission('users.view') }, async (request, reply) => { - const { page, limit, skip, sort, order, search } = parsePagination(request.query as Record); - const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id'; - - const where = search - ? { - OR: [ - { username: { contains: search } }, - { email: { contains: search } }, - { first_name: { contains: search } }, - { last_name: { contains: search } }, - ], - } - : {}; - - const [users, total] = await Promise.all([ - prisma.users.findMany({ - where, - skip, - take: limit, - orderBy: { [sortField]: order }, - 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 } }, - }, - }), - prisma.users.count({ where }), - ]); + const params = parsePagination(request.query as Record); + const result = await listUsers(params); return reply.send({ success: true, - data: users, - pagination: buildPaginationMeta(total, page, limit), + data: result.users, + pagination: buildPaginationMeta(result.total, result.page, result.limit), }); }); @@ -55,16 +24,8 @@ export default async function usersRoutes(fastify: FastifyInstance): Promise('/:id', { preHandler: requirePermission('users.view') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const user = await prisma.users.findUnique({ - where: { id }, - 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 } }, - }, - }); + const user = await getUser(id); if (!user) return error(reply, 'Uživatel nenalezen', 404); return success(reply, user); }); @@ -75,54 +36,28 @@ export default async function usersRoutes(fastify: FastifyInstance): Promise = {}; - - // Username validation and uniqueness - if (body.username !== undefined) { - const newUsername = String(body.username).trim(); - if (newUsername !== existing.username) { - const existingUsername = await prisma.users.findFirst({ where: { username: newUsername } }); - if (existingUsername) { - return error(reply, 'Uživatelské jméno již existuje', 409); - } - } - data.username = newUsername; - } - - // Email validation and uniqueness - if (body.email !== undefined) { - const newEmail = String(body.email).trim(); - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { - return error(reply, 'Neplatný formát e-mailu', 400); - } - const existingEmail = await prisma.users.findFirst({ - where: { email: newEmail, id: { not: id } }, - }); - if (existingEmail) { - return error(reply, 'E-mail již existuje', 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) { - return error(reply, 'Heslo musí mít alespoň 8 znaků', 400); - } - data.password_hash = await bcrypt.hash(newPassword, config.security.bcryptCost); - data.password_changed_at = new Date(); - } - - await prisma.users.update({ where: { id }, data }); + const result = await updateUser(id, parsed.data); + if ('error' in result) return error(reply, result.error, result.status); await logAudit({ request, @@ -186,7 +76,7 @@ export default async function usersRoutes(fastify: FastifyInstance): Promise = {}; + + // Username validation and uniqueness + if (body.username !== undefined) { + const newUsername = String(body.username).trim(); + if (newUsername !== existing.username) { + const existingUsername = await prisma.users.findFirst({ where: { username: newUsername } }); + if (existingUsername) { + return { error: 'Uživatelské jméno již existuje', status: 409 } as const; + } + } + data.username = newUsername; + } + + // Email validation and uniqueness + if (body.email !== undefined) { + const newEmail = String(body.email).trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + return { error: 'Neplatný formát e-mailu', status: 400 } as const; + } + const existingEmail = await prisma.users.findFirst({ + where: { email: newEmail, id: { not: id } }, + }); + if (existingEmail) { + return { error: 'E-mail již existuje', status: 409 } as const; + } + 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) { + return { error: 'Heslo musí mít alespoň 8 znaků', status: 400 } as const; + } + data.password_hash = await bcrypt.hash(newPassword, config.security.bcryptCost); + data.password_changed_at = new Date(); + } + + await prisma.users.update({ where: { id }, data }); + + 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.refresh_tokens.deleteMany({ where: { user_id: id } }); + await prisma.users.delete({ where: { id } }); + + return { id, username: existing.username } as const; +}