import { FastifyInstance } from "fastify"; import { z } from "zod"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, paginated, error } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; // DELETE_ALL_CONFIRM must be sent literally in the body to authorize a // full audit-log wipe. Protects against accidental / automated wipes of // forensic history. const DELETE_ALL_CONFIRM = "DELETE_ALL_AUDIT" as const; const AuditCleanupSchema = z.strictObject({ days: z.number().int().nonnegative().optional(), confirm: z.string().optional(), }); export default async function auditLogRoutes( fastify: FastifyInstance, ): Promise { fastify.get( "/", { preHandler: requirePermission("settings.audit") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, order, search } = parsePagination(query); const where: Record = {}; if (query.action) where.action = String(query.action); if (query.entity_type) where.entity_type = String(query.entity_type); if (query.user_id) { const userId = Number(query.user_id); if (!Number.isInteger(userId) || userId <= 0) { return error(reply, "Neplatné ID uživatele", 400); } where.user_id = userId; } if (search) where.description = { contains: search }; if (query.date_from || query.date_to) { const dateFilter: Record = {}; if (query.date_from) { // LOCAL midnight, symmetric with the date_to side below — a bare // new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague) and // silently excluded events from the first morning hours. const from = new Date(String(query.date_from) + "T00:00:00"); if (isNaN(from.getTime())) { return error(reply, "Neplatné datum od", 400); } dateFilter.gte = from; } if (query.date_to) { const to = new Date(String(query.date_to) + "T23:59:59"); if (isNaN(to.getTime())) { return error(reply, "Neplatné datum do", 400); } dateFilter.lte = to; } where.created_at = dateFilter; } const [logs, total] = await Promise.all([ prisma.audit_logs.findMany({ where, skip, take: limit, orderBy: { created_at: order }, }), prisma.audit_logs.count({ where }), ]); return paginated(reply, logs, buildPaginationMeta(total, page, limit)); }, ); // POST /api/admin/audit-log/cleanup — delete old audit logs // days=0 with confirm="DELETE_ALL_AUDIT" wipes everything (intentionally // a two-step path so a stray API call cannot destroy forensic history). fastify.post( "/cleanup", { preHandler: requirePermission("settings.audit") }, async (request, reply) => { const parsed = parseBody(AuditCleanupSchema, request.body); if ("error" in parsed) { return error(reply, parsed.error, 400); } const { days, confirm } = parsed.data; // Wipe-all path: requires explicit confirmation literal. if (days === 0) { if (confirm !== DELETE_ALL_CONFIRM) { return error( reply, `Pro smazání všech audit logů je nutné odeslat confirm: "${DELETE_ALL_CONFIRM}"`, 400, ); } const result = await prisma.audit_logs.deleteMany({}); await logAudit({ request, authData: request.authData, action: "delete", entityType: "audit_logs", description: `Uživatel ${request.authData?.username ?? "unknown"} smazal všechny audit logy, počet: ${result.count}`, }); return success(reply, null, 200, `Smazáno ${result.count} záznamů`); } if (days !== undefined && days > 0) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const result = await prisma.audit_logs.deleteMany({ where: { created_at: { lt: cutoff } }, }); await logAudit({ request, authData: request.authData, action: "delete", entityType: "audit_logs", description: `Uživatel ${request.authData?.username ?? "unknown"} smazal audit logy starší než ${days} dní, počet: ${result.count}`, }); return success( reply, null, 200, `Smazáno ${result.count} záznamů starších než ${days} dní`, ); } return error(reply, "Zadejte počet dní", 400); }, ); }