- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
for inactive/locked users, locked_until check in loadAuthData, TOTP
fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
numbering.service releaseSequence no-op, nas-offers filename sanitize,
Content-Disposition header injection fix, mojibake Czech strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
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<void> {
|
|
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
|
const query = request.query as Record<string, unknown>;
|
|
const { page, limit, skip, order } = parsePagination(query);
|
|
const authData = request.authData!;
|
|
const isAdmin = authData.permissions.includes("trips.admin");
|
|
|
|
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 [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: {
|
|
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<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),
|
|
};
|
|
}
|
|
|
|
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: requirePermission("trips.view") },
|
|
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!;
|
|
|
|
if (body.end_km < body.start_km) {
|
|
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
|
|
}
|
|
|
|
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!;
|
|
|
|
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.admin");
|
|
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.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");
|
|
},
|
|
);
|
|
}
|