Files
app/src/routes/admin/trips.ts
BOHA 1826fc7976 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>
2026-06-10 09:59:47 +02:00

495 lines
16 KiB
TypeScript

import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAnyPermission, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
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,
): Promise<void> {
fastify.get(
"/",
{
preHandler: requireAnyPermission(
"trips.manage",
"trips.record",
"trips.history",
),
},
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const where = buildTripsWhere(query, request.authData!);
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
// 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 } },
},
}),
prisma.trips.count({ where }),
]);
return paginated(reply, trips, buildPaginationMeta(total, page, limit));
},
);
// 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",
{ preHandler: requireAnyPermission("trips.record", "trips.manage") },
async (_request, reply) => {
const users = await prisma.users.findMany({
where: {
is_active: true,
roles: {
role_permissions: {
some: { permissions: { name: "trips.record" } },
},
},
},
select: {
id: true,
first_name: true,
last_name: true,
username: true,
},
orderBy: { last_name: "asc" },
});
return success(
reply,
users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
})),
);
},
);
// GET /api/admin/trips/print — print data for trip report
fastify.get(
"/print",
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
// 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,
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
// trip_date is date-only — id tiebreak keeps same-day rows stable
orderBy: [{ trip_date: "asc" }, { id: "asc" }],
});
const vehicles = await prisma.vehicles.findMany({
orderBy: { name: "asc" },
});
const users = await prisma.users.findMany({
where: { is_active: true },
select: { id: true, first_name: true, last_name: true },
orderBy: { last_name: "asc" },
});
let totalKm = 0;
let businessKm = 0;
let privateKm = 0;
for (const t of trips) {
// 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;
}
return success(reply, {
trips,
vehicles,
users: users.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim(),
})),
totals: {
total_km: totalKm,
business_km: businessKm,
private_km: privateKm,
count: trips.length,
},
});
},
);
// GET /api/admin/trips/last-km/:vehicleId
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId",
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const vehicleId = parseId((request.params as any).vehicleId, reply);
if (vehicleId === null) return;
const [lastTrip, 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 },
}),
]);
const lastKm = lastTrip
? Number(lastTrip.end_km)
: Number(vehicle?.initial_km ?? 0);
return success(reply, { last_km: lastKm });
},
);
fastify.post(
"/",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const parsed = parseBody(CreateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
if (body.end_km < body.start_km) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
const vehicleId = Number(body.vehicle_id);
const trip = await prisma.trips.create({
data: {
vehicle_id: vehicleId,
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: !!body.is_business,
notes: body.notes ? String(body.notes) : null,
},
});
// 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,
authData,
action: "create",
entityType: "trip",
entityId: trip.id,
description: `Vytvořena jízda`,
});
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
},
);
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
const parsed = parseBody(UpdateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
if (
body.end_km != null &&
body.start_km != null &&
body.end_km < body.start_km
) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Ownership check — same as DELETE handler
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění upravit tuto jízdu", 403);
}
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);
if (body.end_km !== undefined) data.end_km = Number(body.end_km);
if (body.route_from !== undefined)
data.route_from = String(body.route_from);
if (body.route_to !== undefined) data.route_to = String(body.route_to);
if (body.is_business !== undefined) data.is_business = !!body.is_business;
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
await prisma.trips.update({ where: { id }, data });
// 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({
request,
authData,
action: "update",
entityType: "trip",
entityId: id,
description: `Upravena jízda`,
oldValues: existing,
newValues: data,
});
return success(reply, { id }, 200, "Záznam byl aktualizován");
},
);
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } });
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Allow users to delete their own trips, admins can delete any
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
}
const vehicleId = existing.vehicle_id;
await prisma.trips.delete({ where: { id } });
// 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,
authData,
action: "delete",
entityType: "trip",
entityId: id,
description: `Smazána jízda`,
});
return success(reply, { id }, 200, "Záznam byl smazán");
},
);
}