fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -418,6 +418,8 @@ export default async function attendanceRoutes(
|
||||
departure_lng: body.departure_lng,
|
||||
departure_accuracy: body.departure_accuracy,
|
||||
departure_address: body.departure_address,
|
||||
break_start: body.break_start,
|
||||
break_end: body.break_end,
|
||||
notes: body.notes,
|
||||
project_id: body.project_id,
|
||||
leave_type: body.leave_type,
|
||||
|
||||
@@ -259,6 +259,9 @@ export default async function ordersPdfRoutes(
|
||||
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
// The confirmation PDF never renders the PO attachment — don't pull
|
||||
// the blob just to read the order header/items.
|
||||
omit: { attachment_data: true },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { config } from "../../config/env";
|
||||
import {
|
||||
@@ -110,10 +111,12 @@ export default async function ordersRoutes(
|
||||
const attachment = await getOrderAttachment(id);
|
||||
if (!attachment) return error(reply, "Příloha nenalezena", 404);
|
||||
|
||||
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("inline", attachment.filename),
|
||||
)
|
||||
.send(attachment.data);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import prisma from "../../config/database";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
@@ -294,10 +295,14 @@ export default async function receivedInvoicesRoutes(
|
||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||
|
||||
const mime = invoice.file_mime || "application/pdf";
|
||||
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
|
||||
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
|
||||
// window for viewing, not as a forced download.
|
||||
return reply
|
||||
.type(mime)
|
||||
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("inline", invoice.file_name),
|
||||
)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -264,7 +264,8 @@ export default async function totpRoutes(
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpBackupSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, backup_code: code } = parsed.data;
|
||||
const { login_token, backup_code: code, remember_me } = parsed.data;
|
||||
const rememberMe = remember_me ?? false;
|
||||
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
@@ -393,14 +394,18 @@ export default async function totpRoutes(
|
||||
.update(refreshTokenRaw)
|
||||
.digest("hex");
|
||||
|
||||
// Mirror /login/totp: honor remember_me so a backup-code login keeps
|
||||
// the same session longevity the user asked for at the password step.
|
||||
const refreshExpiresIn = rememberMe
|
||||
? config.jwt.refreshTokenRememberExpiry
|
||||
: config.jwt.refreshTokenSessionExpiry;
|
||||
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: refreshTokenHash,
|
||||
expires_at: new Date(
|
||||
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000,
|
||||
),
|
||||
remember_me: false,
|
||||
expires_at: new Date(Date.now() + refreshExpiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
@@ -411,7 +416,7 @@ export default async function totpRoutes(
|
||||
secure: config.isProduction,
|
||||
sameSite: "strict",
|
||||
path: "/api/admin",
|
||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||
maxAge: refreshExpiresIn,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -6,6 +6,99 @@ import { success, paginated, error, parseId } from "../../utils/response";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
|
||||
import { AuthData } from "../../types";
|
||||
|
||||
/**
|
||||
* Shared filter/scoping logic for the trip list AND the stats endpoint — the
|
||||
* stats MUST honor exactly the same filters as the list so the cards always
|
||||
* describe the rows the list is paging through.
|
||||
*
|
||||
* Scoping: admins / trips.manage holders see all trips (optionally filtered
|
||||
* by user_id); everyone else is hard-scoped to their own trips.
|
||||
*/
|
||||
function buildTripsWhere(
|
||||
query: Record<string, unknown>,
|
||||
authData: AuthData,
|
||||
): Record<string, unknown> {
|
||||
// Admin role bypasses permission checks entirely (requireAnyPermission
|
||||
// lets it through with an empty permissions list), so treat the admin
|
||||
// role as a manager for scoping purposes too.
|
||||
const isManager =
|
||||
authData.roleName === "admin" ||
|
||||
authData.permissions.includes("trips.manage");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isManager) where.user_id = authData.userId;
|
||||
else if (query.user_id) {
|
||||
// Non-numeric filter values are ignored (like an invalid month below) —
|
||||
// NaN in a Prisma where clause would throw and surface as a 500.
|
||||
const uid = Number(query.user_id);
|
||||
if (Number.isFinite(uid)) where.user_id = uid;
|
||||
}
|
||||
if (query.vehicle_id) {
|
||||
const vid = Number(query.vehicle_id);
|
||||
if (Number.isFinite(vid)) where.vehicle_id = vid;
|
||||
}
|
||||
|
||||
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
|
||||
if (query.month) {
|
||||
const monthStr = String(query.month);
|
||||
let yr: number, mo: number;
|
||||
if (monthStr.includes("-")) {
|
||||
// Combined YYYY-MM format
|
||||
const [yStr, mStr] = monthStr.split("-");
|
||||
yr = Number(yStr);
|
||||
mo = Number(mStr);
|
||||
} else if (query.year) {
|
||||
yr = Number(query.year);
|
||||
mo = Number(query.month);
|
||||
} else {
|
||||
yr = NaN;
|
||||
mo = NaN;
|
||||
}
|
||||
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a vehicle's actual_km as MAX(end_km) over its trips, falling
|
||||
* back to initial_km when the vehicle has no trips. Shared by the POST,
|
||||
* PUT and DELETE handlers so the odometer logic can't drift.
|
||||
*/
|
||||
async function recomputeVehicleActualKm(vehicleId: number): Promise<void> {
|
||||
const [maxTrip, vehicle] = await Promise.all([
|
||||
prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
}),
|
||||
prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
}),
|
||||
]);
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: {
|
||||
actual_km: maxTrip
|
||||
? Number(maxTrip.end_km)
|
||||
: Number(vehicle?.initial_km ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function tripsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -22,55 +115,16 @@ export default async function tripsRoutes(
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
const authData = request.authData!;
|
||||
// Admin role bypasses permission checks entirely (requireAnyPermission
|
||||
// lets it through with an empty permissions list), so treat the admin
|
||||
// role as a manager for scoping purposes too.
|
||||
const isAdmin =
|
||||
authData.roleName === "admin" ||
|
||||
authData.permissions.includes("trips.manage");
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isAdmin) where.user_id = authData.userId;
|
||||
else if (query.user_id) where.user_id = Number(query.user_id);
|
||||
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
|
||||
|
||||
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
|
||||
if (query.month) {
|
||||
const monthStr = String(query.month);
|
||||
let yr: number, mo: number;
|
||||
if (monthStr.includes("-")) {
|
||||
// Combined YYYY-MM format
|
||||
const [yStr, mStr] = monthStr.split("-");
|
||||
yr = Number(yStr);
|
||||
mo = Number(mStr);
|
||||
} else if (query.year) {
|
||||
yr = Number(query.year);
|
||||
mo = Number(query.month);
|
||||
} else {
|
||||
yr = NaN;
|
||||
mo = NaN;
|
||||
}
|
||||
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
}
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
const [trips, total] = await Promise.all([
|
||||
prisma.trips.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { trip_date: order },
|
||||
// trip_date is date-only — same-day rows need the id tiebreak for
|
||||
// deterministic pagination.
|
||||
orderBy: [{ trip_date: order }, { id: order }],
|
||||
include: {
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
@@ -83,6 +137,60 @@ export default async function tripsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/trips/stats — count/km totals over the WHOLE filtered set
|
||||
// (not one page). Honors exactly the same filters + user scoping as the
|
||||
// list (month/year, vehicle_id, user_id for managers) so the stat cards
|
||||
// can't drift from the rows the list is paging through.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{
|
||||
preHandler: requireAnyPermission(
|
||||
"trips.manage",
|
||||
"trips.record",
|
||||
"trips.history",
|
||||
),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
// Legacy PHP-era rows can carry a stored `distance` that differs from
|
||||
// end_km - start_km, and every row renderer prefers it
|
||||
// (`distance ?? end_km - start_km`) — the totals must apply the same
|
||||
// coalesce or the stat cards would disagree with the visible rows.
|
||||
// That rules out a plain groupBy aggregate.
|
||||
const rows = await prisma.trips.findMany({
|
||||
where,
|
||||
select: {
|
||||
distance: true,
|
||||
start_km: true,
|
||||
end_km: true,
|
||||
is_business: true,
|
||||
},
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of rows) {
|
||||
const km =
|
||||
t.distance != null
|
||||
? Number(t.distance)
|
||||
: Number(t.end_km) - Number(t.start_km);
|
||||
count += 1;
|
||||
if (t.is_business) businessKm += km;
|
||||
else privateKm += km;
|
||||
}
|
||||
|
||||
return success(reply, {
|
||||
count,
|
||||
total_km: businessKm + privateKm,
|
||||
business_km: businessKm,
|
||||
private_km: privateKm,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/trips/users — users with trips.record permission
|
||||
fastify.get(
|
||||
"/users",
|
||||
@@ -121,28 +229,11 @@ export default async function tripsRoutes(
|
||||
{ preHandler: requirePermission("trips.manage") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const filterUserId = query.user_id ? Number(query.user_id) : null;
|
||||
const filterVehicleId = query.vehicle_id
|
||||
? Number(query.vehicle_id)
|
||||
: null;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||
if (query.month && query.year) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const yr = Number(query.year);
|
||||
const mo = Number(query.month);
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
// Same shared filter source as the list + stats endpoints (incl. the
|
||||
// month 1-12 validation). /print is manager-only (trips.manage guard),
|
||||
// so buildTripsWhere always takes its manager branch here — user_id /
|
||||
// vehicle_id / month filters behave exactly as before.
|
||||
const where = buildTripsWhere(query, request.authData!);
|
||||
|
||||
const trips = await prisma.trips.findMany({
|
||||
where,
|
||||
@@ -150,7 +241,8 @@ export default async function tripsRoutes(
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
vehicles: { select: { id: true, name: true, spz: true } },
|
||||
},
|
||||
orderBy: { trip_date: "asc" },
|
||||
// trip_date is date-only — id tiebreak keeps same-day rows stable
|
||||
orderBy: [{ trip_date: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
const vehicles = await prisma.vehicles.findMany({
|
||||
@@ -166,7 +258,13 @@ export default async function tripsRoutes(
|
||||
let businessKm = 0;
|
||||
let privateKm = 0;
|
||||
for (const t of trips) {
|
||||
const dist = Number(t.end_km) - Number(t.start_km);
|
||||
// Same coalesce as the row renderers and /stats: legacy rows may
|
||||
// store a `distance` differing from end_km - start_km, and the print
|
||||
// footer must match the sum of the printed rows.
|
||||
const dist =
|
||||
t.distance != null
|
||||
? Number(t.distance)
|
||||
: Number(t.end_km) - Number(t.start_km);
|
||||
totalKm += dist;
|
||||
if (t.is_business) businessKm += dist;
|
||||
else privateKm += dist;
|
||||
@@ -251,21 +349,13 @@ export default async function tripsRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
// Recompute actual_km from MAX(end_km) across all trips (same as
|
||||
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
|
||||
// regress the odometer when a back-dated trip with a lower end_km is
|
||||
// entered after a later trip with a higher reading.
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: Number(maxTrip.end_km) },
|
||||
});
|
||||
}
|
||||
// Recompute actual_km from MAX(end_km) across all trips (same helper
|
||||
// as PUT/DELETE). Setting it unconditionally to this trip's end_km
|
||||
// would regress the odometer when a back-dated trip with a lower
|
||||
// end_km is entered after a later trip with a higher reading. (The
|
||||
// helper's initial_km fallback is unreachable here — the just-created
|
||||
// trip guarantees at least one row.)
|
||||
await recomputeVehicleActualKm(vehicleId);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
@@ -312,6 +402,19 @@ export default async function tripsRoutes(
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.vehicle_id !== undefined) {
|
||||
const targetVehicleId = Number(body.vehicle_id);
|
||||
if (targetVehicleId !== existing.vehicle_id) {
|
||||
// Schema only guarantees a positive integer — without this check a
|
||||
// nonexistent id would hit the FK as P2003 and surface as a 500.
|
||||
const vehicleExists = await prisma.vehicles.findUnique({
|
||||
where: { id: targetVehicleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!vehicleExists) return error(reply, "Vozidlo nenalezeno", 400);
|
||||
}
|
||||
data.vehicle_id = targetVehicleId;
|
||||
}
|
||||
if (body.trip_date !== undefined)
|
||||
data.trip_date = new Date(String(body.trip_date));
|
||||
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
|
||||
@@ -325,20 +428,19 @@ export default async function tripsRoutes(
|
||||
|
||||
await prisma.trips.update({ where: { id }, data });
|
||||
|
||||
// Update vehicle actual_km if end_km changed
|
||||
if (body.end_km !== undefined) {
|
||||
const vehicleId = existing.vehicle_id;
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
if (maxTrip) {
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: { actual_km: Number(maxTrip.end_km) },
|
||||
});
|
||||
}
|
||||
// Recompute odometers when the km reading or the vehicle changed: the
|
||||
// trip's (possibly new) vehicle, and — when the trip moved between
|
||||
// vehicles — the old one too, which may have lost its MAX(end_km) row.
|
||||
const newVehicleId =
|
||||
body.vehicle_id !== undefined
|
||||
? Number(body.vehicle_id)
|
||||
: existing.vehicle_id;
|
||||
const vehicleChanged = newVehicleId !== existing.vehicle_id;
|
||||
if (body.end_km !== undefined || vehicleChanged) {
|
||||
await recomputeVehicleActualKm(newVehicleId);
|
||||
}
|
||||
if (vehicleChanged) {
|
||||
await recomputeVehicleActualKm(existing.vehicle_id);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
@@ -348,6 +450,8 @@ export default async function tripsRoutes(
|
||||
entityType: "trip",
|
||||
entityId: id,
|
||||
description: `Upravena jízda`,
|
||||
oldValues: existing,
|
||||
newValues: data,
|
||||
});
|
||||
return success(reply, { id }, 200, "Záznam byl aktualizován");
|
||||
},
|
||||
@@ -372,24 +476,9 @@ export default async function tripsRoutes(
|
||||
const vehicleId = existing.vehicle_id;
|
||||
await prisma.trips.delete({ where: { id } });
|
||||
|
||||
// Recalculate vehicle actual_km after deletion
|
||||
const maxTrip = await prisma.trips.findFirst({
|
||||
where: { vehicle_id: vehicleId },
|
||||
orderBy: { end_km: "desc" },
|
||||
select: { end_km: true },
|
||||
});
|
||||
const vehicle = await prisma.vehicles.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { initial_km: true },
|
||||
});
|
||||
await prisma.vehicles.update({
|
||||
where: { id: vehicleId },
|
||||
data: {
|
||||
actual_km: maxTrip
|
||||
? Number(maxTrip.end_km)
|
||||
: (vehicle?.initial_km ?? 0),
|
||||
},
|
||||
});
|
||||
// Recalculate vehicle actual_km after deletion (falls back to
|
||||
// initial_km when this was the vehicle's last trip).
|
||||
await recomputeVehicleActualKm(vehicleId);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { config } from "../../config/env";
|
||||
import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { success, error, parseId, paginated } from "../../utils/response";
|
||||
import { contentDisposition } from "../../utils/content-disposition";
|
||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||
@@ -1294,6 +1295,41 @@ export default async function warehouseRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /receipts/:id/attachments/:attachmentId — download attachment from NAS
|
||||
fastify.get<{ Params: { id: string; attachmentId: string } }>(
|
||||
"/receipts/:id/attachments/:attachmentId",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const attachmentId = parseId(request.params.attachmentId, reply);
|
||||
if (attachmentId === null) return;
|
||||
|
||||
const attachment = await prisma.sklad_receipt_attachments.findUnique({
|
||||
where: { id: attachmentId },
|
||||
});
|
||||
// 404 (not 400) when the attachment belongs to a different receipt:
|
||||
// a read endpoint should not confirm that the id exists elsewhere.
|
||||
if (!attachment || attachment.receipt_id !== id) {
|
||||
return error(reply, "Příloha nenalezena", 404);
|
||||
}
|
||||
|
||||
const nasFile = nasInvoicesManager.readReceived(attachment.file_path);
|
||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||
|
||||
const mime = attachment.file_mime || "application/octet-stream";
|
||||
// "attachment": the UI always downloads via blob + a.download, never
|
||||
// renders inline — and attachment is safer with client-controlled mime.
|
||||
return reply
|
||||
.type(mime)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
contentDisposition("attachment", attachment.file_name),
|
||||
)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /receipts/:id/attachments — upload attachment
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
"/receipts/:id/attachments",
|
||||
|
||||
Reference in New Issue
Block a user