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, authData: AuthData, ): Record { // 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 = {}; 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 { 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 { fastify.get( "/", { preHandler: requireAnyPermission( "trips.manage", "trips.record", "trips.history", ), }, async (request, reply) => { const query = request.query as Record; 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; 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; // 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.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.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 = {}; 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.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"); }, ); }