import { FastifyInstance } from "fastify"; import prisma from "../../config/database"; import { requireAuth, requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema"; export default async function tripsRoutes( fastify: FastifyInstance, ): Promise { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, order } = parsePagination(query); const authData = request.authData!; const isAdmin = authData.permissions.includes("trips.admin"); const where: Record = {}; 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) { where.trip_date = { gte: new Date(yr, mo - 1, 1), lt: new Date(yr, mo, 1), }; } } const [trips, total] = await Promise.all([ prisma.trips.findMany({ where, skip, take: limit, orderBy: { trip_date: 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 reply.send({ success: true, data: trips, pagination: buildPaginationMeta(total, page, limit), }); }); // GET /api/admin/trips/users — users with trips.record permission fastify.get( "/users", { preHandler: requireAuth }, async (_request, reply) => { const users = await prisma.users.findMany({ where: { is_active: true, roles: { is: { OR: [ { name: "admin" }, { 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.admin") }, async (request, reply) => { const query = request.query as Record; const filterUserId = query.user_id ? Number(query.user_id) : null; const filterVehicleId = query.vehicle_id ? Number(query.vehicle_id) : null; const where: Record = {}; if (filterUserId) where.user_id = filterUserId; if (filterVehicleId) where.vehicle_id = filterVehicleId; if (query.month && query.year) { where.trip_date = { gte: new Date(Number(query.year), Number(query.month) - 1, 1), lt: new Date(Number(query.year), Number(query.month), 1), }; } 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 } }, }, orderBy: { trip_date: "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) { const dist = 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: requireAuth }, async (request, reply) => { const vehicleId = parseInt(request.params.vehicleId, 10); if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400); 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: requireAuth }, 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!; const trip = await prisma.trips.create({ data: { vehicle_id: Number(body.vehicle_id), 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, }, }); await prisma.vehicles.update({ where: { id: Number(body.vehicle_id) }, data: { actual_km: Number(body.end_km) }, }); 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: requireAuth }, async (request, reply) => { const id = parseInt(request.params.id, 10); if (isNaN(id)) return error(reply, "Neplatné ID", 400); const parsed = parseBody(UpdateTripSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const authData = request.authData!; 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.admin"); 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.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 }); // 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) }, }); } } await logAudit({ request, authData, action: "update", entityType: "trip", entityId: id, description: `Upravena jízda`, }); return success(reply, { id }, 200, "Záznam byl aktualizován"); }, ); fastify.delete<{ Params: { id: string } }>( "/:id", { preHandler: requireAuth }, async (request, reply) => { const id = parseInt(request.params.id, 10); if (isNaN(id)) return error(reply, "Neplatné ID", 400); 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.admin"); 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 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), }, }); 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"); }, ); }