Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
failed deficit line rolls back the surplus corrective receipt — retries
no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
(in-transaction, clamped at 0)
High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
(Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
hours against its own balance (mirrors createLeave)
Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
(recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
@db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
/items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
settings.company|settings.system; Settings System tab no longer clobbers
Firma numbering patterns; draft invoices hide the dead PDF button;
dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
audit-log + invoice month buckets day-shift fixes
Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.
~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
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<void> {
|
|
fastify.get(
|
|
"/",
|
|
{ preHandler: requirePermission("settings.audit") },
|
|
async (request, reply) => {
|
|
const query = request.query as Record<string, unknown>;
|
|
const { page, limit, skip, order, search } = parsePagination(query);
|
|
|
|
const where: Record<string, unknown> = {};
|
|
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<string, Date> = {};
|
|
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);
|
|
},
|
|
);
|
|
}
|