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:
BOHA
2026-06-10 09:59:47 +02:00
parent d08e55a41a
commit 1826fc7976
44 changed files with 2812 additions and 622 deletions

View File

@@ -0,0 +1,309 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import attendanceRoutes from "../routes/admin/attendance";
// ---------------------------------------------------------------------------
// Regression tests for the attendance create/edit wire format.
//
// The admin create/edit forms send COMBINED local datetimes
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
// for arrival_time / departure_time / break_start / break_end, and the
// service parses them with `new Date(...)`. Commit 519edce validated these
// fields with `timeString` (bare HH:MM), which rejected every create/edit
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
// entirely, so breaks were silently stripped on create. These tests pin the
// combined-datetime contract at the HTTP route level.
// ---------------------------------------------------------------------------
// All fixtures live on far-future dates so they can never collide with real
// rows in the throwaway test DB; cleanup deletes exactly this range.
const RANGE_START = new Date("2098-01-01T00:00:00");
const RANGE_END = new Date("2099-01-01T00:00:00");
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authPost(path: string, token: string, body: unknown) {
return app.inject({
method: "POST",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authPut(path: string, token: string, body: unknown) {
return app.inject({
method: "PUT",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function cleanupFixtureRows() {
await prisma.attendance.deleteMany({
where: {
user_id: adminUserId,
shift_date: { gte: RANGE_START, lt: RANGE_END },
},
});
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
await cleanupFixtureRows();
});
beforeEach(async () => {
await cleanupFixtureRows();
});
afterAll(async () => {
await cleanupFixtureRows();
if (app) await app.close();
});
describe("POST /api/admin/attendance (combined datetimes)", () => {
it("creates a work record with arrival+departure datetimes and persists them", async () => {
const arrival = "2098-03-10T08:00:00";
const departure = "2098-03-10T16:30:00";
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-10",
leave_type: "work",
arrival_time: arrival,
departure_time: departure,
notes: "attendance_wire_test work",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec).toBeTruthy();
expect(rec!.leave_type).toBe("work");
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
});
it("persists break_start/break_end on create (previously silently stripped)", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-11",
leave_type: "work",
arrival_time: "2098-03-11T08:00:00",
departure_time: "2098-03-11T16:30:00",
break_start: "2098-03-11T12:00:00",
break_end: "2098-03-11T12:30:00",
notes: "attendance_wire_test break",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-11T12:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-11T12:30:00").getTime(),
);
});
it("creates a leave record with NO time fields", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-12",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test leave",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(Number(rec!.leave_hours)).toBe(8);
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
// The AttendanceCreate page historically always sent empty strings for
// the unfilled time fields — even for leave records — which 400'd EVERY
// submit after 519edce. The schema must treat "" as "not set".
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-13",
leave_type: "sick",
leave_hours: 8,
arrival_time: "",
departure_time: "",
break_start: "",
break_end: "",
notes: "attendance_wire_test empty strings",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("sick");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
expect(rec!.break_start).toBeNull();
expect(rec!.break_end).toBeNull();
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 14, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-14T08:00:00"),
departure_time: new Date("2098-03-14T16:30:00"),
notes: "attendance_wire_test update",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "work",
arrival_time: "2098-03-14T22:00:00",
departure_time: "2098-03-15T06:00:00",
break_start: "2098-03-15T02:00:00",
break_end: "2098-03-15T02:30:00",
notes: "attendance_wire_test updated",
},
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.arrival_time?.getTime()).toBe(
new Date("2098-03-14T22:00:00").getTime(),
);
expect(rec!.departure_time?.getTime()).toBe(
new Date("2098-03-15T06:00:00").getTime(),
);
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-15T02:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-15T02:30:00").getTime(),
);
expect(rec!.notes).toBe("attendance_wire_test updated");
});
it("clears times when null is sent (leave-type edit)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 16, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-16T08:00:00"),
departure_time: new Date("2098-03-16T16:30:00"),
notes: "attendance_wire_test to-leave",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "vacation",
leave_hours: 8,
arrival_time: null,
departure_time: null,
break_start: null,
break_end: null,
notes: "attendance_wire_test to-leave",
},
);
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
});

View File

@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach } from "vitest";
import { Prisma } from "@prisma/client";
import { invoiceTotalWithVat } from "../services/invoices.service";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
@@ -118,3 +124,58 @@ describe("invoiceTotalWithVat", () => {
expect(invoiceTotalWithVat(inv)).toBe(968);
});
});
/* -------------------------------------------------------------------------- */
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
/* -------------------------------------------------------------------------- */
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
// which STRIPS unknown keys — so a field missing from the schema is silently
// dropped before the service ever sees it, while the client still gets a
// success response. This block mirrors that exact flow against the real DB.
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("persists an edited billing_text (the schema must not strip it)", async () => {
const draft = await createInvoice({ billing_text: "Původní text" });
invoiceIds.push(draft.id);
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
const parsed = UpdateInvoiceSchema.parse({
billing_text: "Fakturujeme Vám dle objednávky č. 123",
});
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
const res = await updateInvoice(draft.id, parsed);
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
});
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
const draft = await createInvoice({ billing_text: "Text na PDF" });
invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF");
// Explicit null -> cleared (the strFields path maps falsy -> null).
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ billing_text: null }),
);
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBeNull();
});
});

View File

@@ -1,6 +1,17 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { createOrder, listOrders } from "../services/orders.service";
import { config } from "../config/env";
import ordersRoutes from "../routes/admin/orders";
import {
createOrder,
listOrders,
getOrder,
getOrderAttachment,
} from "../services/orders.service";
const createdOrderIds: number[] = [];
const createdCustomerIds: number[] = [];
@@ -80,6 +91,166 @@ describe("listOrders search filter", () => {
});
});
describe("order attachment payload hygiene", () => {
// The PO attachment blob must be served ONLY by GET /:id/attachment —
// list/detail payloads carry attachment_name as the existence signal.
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
const TEST_ADMIN = "orders_list_test_admin";
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
let adminToken: string;
let adminUserId: number;
async function buildOrdersApp() {
const fastify = Fastify({ logger: false });
await fastify.register(cookie);
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
// multipart is registered inside ordersRoutes itself
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
return fastify;
}
beforeAll(async () => {
app = await buildOrdersApp();
// Clean up any leftover test user from a previous failed run
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const adminUser = await prisma.users.create({
data: {
username: TEST_ADMIN,
email: `${TEST_ADMIN}@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Orders",
last_name: "ListTest",
is_active: true,
role_id: adminRole.id,
},
});
adminUserId = adminUser.id;
adminToken = jwt.sign(
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
await prisma.users.deleteMany({ where: { id: adminUserId } });
await app.close();
});
/** One order WITH a stored PO attachment, one WITHOUT. */
async function makeOrderPair() {
const customer = await makeCustomer();
const withRes = await createOrder(
{ ...baseOrder, customer_id: customer.id, create_project: false },
PDF_BYTES,
"po-test.pdf",
);
if (!("id" in withRes)) failResult(withRes);
createdOrderIds.push(withRes.id!);
const withoutRes = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in withoutRes)) failResult(withoutRes);
createdOrderIds.push(withoutRes.id!);
return { withId: withRes.id!, withoutId: withoutRes.id! };
}
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
const { withId, withoutId } = await makeOrderPair();
const list = await listOrders(baseListParams);
const withRow = list.data.find((o) => o.id === withId);
const withoutRow = list.data.find((o) => o.id === withoutId);
expect(withRow).toBeTruthy();
expect(withoutRow).toBeTruthy();
expect("attachment_data" in withRow!).toBe(false);
expect("attachment_data" in withoutRow!).toBe(false);
expect(withRow!.attachment_name).toBe("po-test.pdf");
expect(withoutRow!.attachment_name).toBeNull();
});
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
const { withId, withoutId } = await makeOrderPair();
const withDetail = await getOrder(withId);
const withoutDetail = await getOrder(withoutId);
expect(withDetail).toBeTruthy();
expect(withoutDetail).toBeTruthy();
expect("attachment_data" in withDetail!).toBe(false);
expect("attachment_data" in withoutDetail!).toBe(false);
expect(withDetail!.attachment_name).toBe("po-test.pdf");
expect(withoutDetail!.attachment_name).toBeNull();
});
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
const { withId, withoutId } = await makeOrderPair();
const headers = { authorization: `Bearer ${adminToken}` };
const listRes = await app.inject({
method: "GET",
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
headers,
});
expect(listRes.statusCode).toBe(200);
const listJson = JSON.parse(listRes.body);
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
expect(listRes.body).not.toContain("attachment_data");
const detailRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}`,
headers,
});
expect(detailRes.statusCode).toBe(200);
expect(detailRes.body).not.toContain("attachment_data");
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
const attRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}/attachment`,
headers,
});
expect(attRes.statusCode).toBe(200);
expect(attRes.headers["content-type"]).toContain("application/pdf");
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
const noAttRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withoutId}/attachment`,
headers,
});
expect(noAttRes.statusCode).toBe(404);
});
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
const { withId, withoutId } = await makeOrderPair();
const att = await getOrderAttachment(withId);
expect(att).toBeTruthy();
expect(att!.filename).toBe("po-test.pdf");
expect(att!.data.equals(PDF_BYTES)).toBe(true);
expect(await getOrderAttachment(withoutId)).toBeNull();
});
});
describe("listOrders month filter", () => {
it("returns only orders whose created_at is in the given month", async () => {
const customer = await makeCustomer();

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import * as OTPAuthLib from "otpauth";
import { buildApp, extractCookie } from "./helpers";
import prisma from "../config/database";
import { config } from "../config/env";
import { encrypt } from "../utils/encryption";
let app: Awaited<ReturnType<typeof buildApp>>;
// Fixture prefix so cleanup never touches real data.
const N = "totp_backup_test_";
const TEST_USERNAME = `${N}user`;
// Plaintext backup codes seeded for the fixture user (the enable route stores
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
const BACKUP_CODE_1 = "AAAA1111";
const BACKUP_CODE_2 = "BBBB2222";
let testUserId: number;
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
const cookies = res.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
| string
| undefined;
}
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
async function issueLoginToken(userId: number): Promise<string> {
const raw = crypto.randomBytes(32).toString("hex");
const hash = crypto.createHash("sha256").update(raw).digest("hex");
await prisma.totp_login_tokens.create({
data: {
user_id: userId,
token_hash: hash,
expires_at: new Date(Date.now() + 5 * 60_000),
},
});
return raw;
}
// /totp/backup-verify carries its own per-IP rate limit
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
// more attempts than that — give each request a distinct source IP.
let ipCounter = 0;
function postBackupVerify(payload: Record<string, unknown>) {
ipCounter += 1;
return app.inject({
method: "POST",
url: "/api/admin/totp/backup-verify",
payload,
remoteAddress: `10.99.0.${ipCounter}`,
});
}
async function fixtureUser() {
const user = await prisma.users.findUniqueOrThrow({
where: { id: testUserId },
});
return user;
}
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover fixture from a previous failed run.
const leftovers = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
if (leftovers.length) {
const ids = leftovers.map((u) => u.id);
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
await prisma.totp_login_tokens.deleteMany({
where: { user_id: { in: ids } },
});
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
}
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
const hashedCodes = [
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
];
const user = await prisma.users.create({
data: {
username: TEST_USERNAME,
email: `${N}user@test.local`,
password_hash: await bcrypt.hash(
"irrelevant",
config.security.bcryptCost,
),
first_name: "TotpBackup",
last_name: "Test",
is_active: true,
totp_enabled: true,
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
totp_backup_codes: JSON.stringify(hashedCodes),
role_id: role.id,
},
});
testUserId = user.id;
});
afterAll(async () => {
await prisma.refresh_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.totp_login_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
describe("POST /api/admin/totp/backup-verify", () => {
it("returns 400 for missing fields", async () => {
const res = await postBackupVerify({});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
it("returns 401 for an invalid login token", async () => {
const res = await postBackupVerify({
login_token: "not-a-real-token",
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("rejects a wrong backup code without consuming the stored codes", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: "ZZZZ9999",
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
// Both backup codes must still be stored — nothing consumed.
const user = await fixtureUser();
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
// Reset the failed-attempt counter so this test can't lock the fixture
// user for later tests (max_login_attempts comes from system settings).
await prisma.users.update({
where: { id: testUserId },
data: { failed_login_attempts: 0 },
});
await prisma.totp_login_tokens.deleteMany({
where: { user_id: testUserId },
});
});
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.access_token.length).toBeGreaterThan(0);
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
expect(body.data.user.userId).toBe(testUserId);
// Same login completion as the TOTP path: httpOnly refresh cookie set.
const cookie = extractCookie(res, "refresh_token");
expect(cookie).toBeTruthy();
const raw = rawSetCookie(res, "refresh_token")!;
expect(raw).toMatch(/HttpOnly/i);
expect(raw).toMatch(/SameSite=Strict/i);
// The used backup code must be removed (single-use).
const user = await fixtureUser();
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
expect(remaining).toHaveLength(1);
// The login token must be consumed: replaying it (even with the second,
// still-valid backup code) is rejected.
const replay = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_2,
});
expect(replay.statusCode).toBe(401);
});
it("rejects reuse of an already-consumed backup code", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
await prisma.users.update({
where: { id: testUserId },
data: { failed_login_attempts: 0 },
});
await prisma.totp_login_tokens.deleteMany({
where: { user_id: testUserId },
});
});
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_2,
remember_me: true,
});
expect(res.statusCode).toBe(200);
const raw = rawSetCookie(res, "refresh_token")!;
expect(raw).toMatch(
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
);
const refreshRow = await prisma.refresh_tokens.findFirst({
where: { user_id: testUserId },
orderBy: { id: "desc" },
});
expect(refreshRow?.remember_me).toBe(true);
});
});

541
src/__tests__/trips.test.ts Normal file
View File

@@ -0,0 +1,541 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import tripsRoutes from "../routes/admin/trips";
// ---------------------------------------------------------------------------
// Route-level tests for the trips domain:
//
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
// (UpdateTripSchema previously had no vehicle_id, so the edit modal's
// vehicle change was silently dropped while the UI reported success) and
// recomputes actual_km on BOTH vehicles.
// 2. GET /trips/stats aggregates count/total/business/private km over the
// WHOLE filtered set (not one 25-row page), honors the month filter in
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
// non-managers to their own trips exactly like the list does.
// ---------------------------------------------------------------------------
/** Note-prefix for test-created rows so cleanup never touches real data. */
const N = "trips_test_";
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
let scopeUserId: number;
let scopeRoleId: number;
let scopeToken: string;
let vehicleAId: number;
let vehicleBId: number;
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authGet(path: string, token: string) {
return app.inject({
method: "GET",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
async function authPut(path: string, token: string, body: unknown) {
return app.inject({
method: "PUT",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
function tripRow(params: {
userId: number;
vehicleId: number;
date: string;
startKm: number;
dist: number;
isBusiness: boolean;
}) {
return {
user_id: params.userId,
vehicle_id: params.vehicleId,
trip_date: new Date(params.date),
start_km: params.startKm,
end_km: params.startKm + params.dist,
route_from: "Praha",
route_to: "Brno",
is_business: params.isBusiness,
notes: `${N}fixture`,
};
}
async function cleanupFixtureTrips() {
await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
// Dedicated non-admin user with trips.record + trips.history (but NOT
// trips.manage) for the own-trips scoping tests.
const stamp = Date.now().toString(36);
const role = await prisma.roles.create({
data: {
name: `${N}role_${stamp}`,
display_name: "Trips Scope Test",
},
});
scopeRoleId = role.id;
const perms = await prisma.permissions.findMany({
where: { name: { in: ["trips.record", "trips.history"] } },
select: { id: true },
});
if (perms.length !== 2)
throw new Error(
"Test setup: trips.record/trips.history permissions missing",
);
await prisma.role_permissions.createMany({
data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
});
const scopeUser = await prisma.users.create({
data: {
username: `${N}user_${stamp}`,
first_name: "Trips",
last_name: "Scope",
email: `${N}${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
scopeUserId = scopeUser.id;
if (scopeUserId === adminUserId)
throw new Error("scope user must be distinct from admin");
scopeToken = generateToken({
id: scopeUser.id,
username: scopeUser.username,
roleName: role.name,
});
// Two dedicated vehicles (spz is unique, VarChar(20)).
const vehicleA = await prisma.vehicles.create({
data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
});
const vehicleB = await prisma.vehicles.create({
data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
});
vehicleAId = vehicleA.id;
vehicleBId = vehicleB.id;
await cleanupFixtureTrips();
});
beforeEach(async () => {
await cleanupFixtureTrips();
});
afterAll(async () => {
await cleanupFixtureTrips();
await prisma.vehicles
.deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
.catch(() => {});
if (scopeUserId)
await prisma.users
.deleteMany({ where: { id: scopeUserId } })
.catch(() => {});
if (scopeRoleId)
await prisma.roles
.deleteMany({ where: { id: scopeRoleId } })
.catch(() => {});
if (app) await app.close();
});
describe("PUT /api/admin/trips/:id — vehicle_id", () => {
it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-04-10",
startKm: 1000,
dist: 500,
isBusiness: true,
}),
});
// The edit form sends the id as a string (Select value) — intIdFromForm
// must coerce it.
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
vehicle_id: String(vehicleBId),
});
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
expect(updated!.vehicle_id).toBe(vehicleBId);
// New vehicle picks up the trip's odometer reading…
const vehB = await prisma.vehicles.findUnique({
where: { id: vehicleBId },
});
expect(vehB!.actual_km).toBe(1500);
// …and the old vehicle falls back to initial_km (no trips left on it).
const vehA = await prisma.vehicles.findUnique({
where: { id: vehicleAId },
});
expect(vehA!.actual_km).toBe(500);
});
it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
// Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
// of the vehicle's trips, not just the edited one.
const trip1 = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-09-10",
startKm: 1000,
dist: 500, // end_km 1500
isBusiness: true,
}),
});
const trip2 = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-09-12",
startKm: 1800,
dist: 200, // end_km 2000
isBusiness: true,
}),
});
// Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
end_km: 2200,
});
expect(raise.statusCode).toBe(200);
let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
expect(vehA!.actual_km).toBe(2200);
// …while editing the non-MAX trip recomputes but keeps MAX(end_km).
const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
end_km: 1600,
});
expect(lower.statusCode).toBe(200);
const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
expect(updated!.end_km).toBe(1600);
expect(updated!.vehicle_id).toBe(vehicleAId);
vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
expect(vehA!.actual_km).toBe(2200);
});
it("writes an audit row with oldValues/newValues on update", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-10-10",
startKm: 100,
dist: 50, // end_km 150
isBusiness: true,
}),
});
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
end_km: 180,
route_to: "Ostrava",
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: { action: "update", entity_type: "trip", entity_id: trip.id },
orderBy: { id: "desc" },
});
expect(audit).not.toBeNull();
expect(audit!.user_id).toBe(adminUserId);
// oldValues = the full pre-update row, newValues = the changed fields.
expect(audit!.old_values).toBeTruthy();
expect(audit!.new_values).toBeTruthy();
const oldValues = JSON.parse(audit!.old_values!);
const newValues = JSON.parse(audit!.new_values!);
expect(oldValues.end_km).toBe(150);
expect(oldValues.route_to).toBe("Brno");
expect(oldValues.vehicle_id).toBe(vehicleAId);
expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
});
it("rejects an invalid vehicle_id with 400", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-04-11",
startKm: 100,
dist: 50,
isBusiness: true,
}),
});
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
vehicle_id: "abc",
});
expect(res.statusCode).toBe(400);
const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
expect(unchanged!.vehicle_id).toBe(vehicleAId);
});
});
describe("GET /api/admin/trips/stats", () => {
it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
// 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
const rows = [];
for (let i = 0; i < 20; i++) {
rows.push(
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-05-10",
startKm: 1000 + i * 10,
dist: 10,
isBusiness: true,
}),
);
}
for (let i = 0; i < 10; i++) {
rows.push(
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-05-15",
startKm: 2000 + i * 7,
dist: 7,
isBusiness: false,
}),
);
}
await prisma.trips.createMany({ data: rows });
// The list truncates to the default 25-row page but reports the real total…
const listRes = await authGet(
"/api/admin/trips?month=5&year=2098",
adminToken,
);
expect(listRes.statusCode).toBe(200);
const listBody = listRes.json();
expect(listBody.data).toHaveLength(25);
expect(listBody.pagination.total).toBe(30);
expect(listBody.pagination.total_pages).toBe(2);
// …while the stats endpoint covers all 30 rows.
const res = await authGet(
"/api/admin/trips/stats?month=5&year=2098",
adminToken,
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toEqual({
count: 30,
total_km: 20 * 10 + 10 * 7,
business_km: 200,
private_km: 70,
});
});
it("honors the month filter in both wire formats", async () => {
await prisma.trips.createMany({
data: [
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-06-10",
startKm: 100,
dist: 10,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-06-12",
startKm: 110,
dist: 20,
isBusiness: false,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-07-05",
startKm: 130,
dist: 40,
isBusiness: true,
}),
],
});
// Combined "YYYY-MM" format (TripsHistory)
const june = await authGet(
"/api/admin/trips/stats?month=2098-06",
adminToken,
);
expect(june.statusCode).toBe(200);
expect(june.json().data).toEqual({
count: 2,
total_km: 30,
business_km: 10,
private_km: 20,
});
// Split month+year format (TripsAdmin)
const july = await authGet(
"/api/admin/trips/stats?month=7&year=2098",
adminToken,
);
expect(july.statusCode).toBe(200);
expect(july.json().data).toEqual({
count: 1,
total_km: 40,
business_km: 40,
private_km: 0,
});
});
it("scopes non-managers to their own trips (user_id param ignored)", async () => {
await prisma.trips.createMany({
data: [
// 2 trips for the scope user, 3 for the admin — same month.
tripRow({
userId: scopeUserId,
vehicleId: vehicleAId,
date: "2098-08-05",
startKm: 100,
dist: 10,
isBusiness: true,
}),
tripRow({
userId: scopeUserId,
vehicleId: vehicleAId,
date: "2098-08-06",
startKm: 110,
dist: 15,
isBusiness: false,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-07",
startKm: 200,
dist: 100,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-08",
startKm: 300,
dist: 100,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-09",
startKm: 400,
dist: 100,
isBusiness: true,
}),
],
});
// Non-manager: only own trips, even when explicitly requesting another user.
const own = await authGet(
"/api/admin/trips/stats?month=8&year=2098",
scopeToken,
);
expect(own.statusCode).toBe(200);
expect(own.json().data).toEqual({
count: 2,
total_km: 25,
business_km: 10,
private_km: 15,
});
const spoofed = await authGet(
`/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
scopeToken,
);
expect(spoofed.statusCode).toBe(200);
expect(spoofed.json().data.count).toBe(2);
// The list applies the exact same scoping.
const list = await authGet(
"/api/admin/trips?month=8&year=2098",
scopeToken,
);
expect(list.statusCode).toBe(200);
expect(list.json().data).toHaveLength(2);
// Manager (admin) sees everything in the month.
const all = await authGet(
"/api/admin/trips/stats?month=8&year=2098",
adminToken,
);
expect(all.statusCode).toBe(200);
expect(all.json().data.count).toBe(5);
expect(all.json().data.total_km).toBe(325);
// Manager can filter to a single user.
const filtered = await authGet(
`/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
adminToken,
);
expect(filtered.statusCode).toBe(200);
expect(filtered.json().data.count).toBe(2);
});
});

View File

@@ -1,4 +1,13 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import warehouseRoutes from "../routes/admin/warehouse";
import { nasInvoicesManager } from "../services/nas-financials-manager";
import { securityHeaders } from "../middleware/security";
// ---------------------------------------------------------------------------
@@ -879,6 +889,177 @@ describe("Receipt flow", () => {
});
});
// ---------------------------------------------------------------------------
// 5b. Receipt attachment download
// ---------------------------------------------------------------------------
describe("Receipt attachment download", () => {
let receiptId: number;
let otherReceiptId: number;
let attachmentId: number;
let diacriticAttachmentId: number;
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
const diacriticFileName = "příloha č. 1.pdf";
beforeAll(async () => {
// Item + two receipts (attachment belongs to the first one only)
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_att_download`, unit: "ks" },
});
const itemId = itemRes.json().data.id;
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_a`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
receiptId = receiptRes.json().data.id;
const otherReceiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_b`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
otherReceiptId = otherReceiptRes.json().data.id;
// Attachment row created directly — the upload endpoint needs a writable
// NAS, which is not available in the test environment. The NAS read is
// stubbed per-test on the singleton (same temp-dir/stub seam as the
// nas-document-manager tests); the DB rows and route logic stay real.
const attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: "wh_test_priloha.pdf",
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: filePath,
},
});
attachmentId = attachment.id;
// Diacritic filename — the norm for a Czech company. Node throws on
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: diacriticFileName,
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
},
});
diacriticAttachmentId = diacriticAttachment.id;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("downloads an existing attachment with correct content-type and bytes", async () => {
const readSpy = vi
.spyOn(nasInvoicesManager, "readReceived")
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(res.headers["content-disposition"]).toBe(
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith(filePath);
});
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
data: fileBytes,
fileName: diacriticFileName,
});
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const disposition = res.headers["content-disposition"];
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
expect(disposition).toContain(
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
);
// … alongside an ASCII-only fallback, so the header never carries
// chars > U+00FF (which Node's setHeader rejects → 500).
expect(disposition).toBe(
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
});
it("returns 404 when the attachment belongs to a different receipt", async () => {
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
// Ownership is rejected before any NAS access
expect(readSpy).not.toHaveBeenCalled();
});
it("returns 404 for a nonexistent attachment id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("returns 404 when the file is missing on the NAS", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("rejects download without warehouse.view permission (403)", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(noPermToken),
});
expect(res.statusCode).toBe(403);
});
});
// ---------------------------------------------------------------------------
// 6. Issue flow
// ---------------------------------------------------------------------------

View File

@@ -1,6 +1,7 @@
import { useState, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -134,6 +135,28 @@ export default function DashProfile({
// locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup);
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
// URI to an external QR service: the production CSP (img-src) blocks it and
// it would leak the TOTP secret to a third party. A data: URL is allowed by
// the CSP. Derived via useQuery (not an effect) per repo conventions.
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
queryKey: ["totp", "qr", totpQrUri],
enabled: !!totpQrUri,
staleTime: Infinity,
// The URI embeds the TOTP secret — drop it from the cache as soon as the
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
gcTime: 0,
retry: false,
queryFn: async () => {
try {
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
} catch (err) {
console.error("DashProfile: generování QR kódu selhalo", err);
throw err;
}
},
});
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: "",
@@ -549,11 +572,11 @@ export default function DashProfile({
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</Typography>
{totpQrUri && (
{totpQrUri && totpQrDataUrl && (
<Box sx={{ textAlign: "center", mb: 2 }}>
<Box
component="img"
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
src={totpQrDataUrl}
alt="TOTP QR Code"
sx={{
width: 200,
@@ -561,10 +584,23 @@ export default function DashProfile({
borderRadius: 2,
border: 1,
borderColor: "divider",
// The QR must stay scannable in dark mode — keep it on a
// white tile instead of inheriting the dark paper bg.
bgcolor: "common.white",
}}
/>
</Box>
)}
{totpQrUri && totpQrFailed && (
<Typography
variant="body2"
color="warning.main"
sx={{ mb: 2, textAlign: "center" }}
>
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
aplikace ručně.
</Typography>
)}
{totpSecret && (
<Box sx={{ mb: 2 }}>
<Typography

View File

@@ -122,7 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
},
[],
);
);
const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
@@ -260,17 +260,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
login_token: loginToken,
totp_code: code,
remember_me: remember,
isBackup,
}),
});
// Backup codes have their own endpoint + schema (8-char codes would
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
// complete the same login flow: tokens issued + refresh cookie set.
const response = await fetch(
isBackup
? `${API_BASE}/totp/backup-verify`
: `${API_BASE}/login/totp`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(
isBackup
? {
login_token: loginToken,
backup_code: code,
remember_me: remember,
}
: {
login_token: loginToken,
totp_code: code,
remember_me: remember,
},
),
},
);
const data = await response.json();
if (data.success) {
setAccessTokenFn(data.data.access_token, data.data.expires_in);

View File

@@ -6,6 +6,7 @@ import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
combineDatetime,
getDatePart,
getTimePart,
formatDate,
@@ -133,9 +134,6 @@ interface DeleteConfirmState {
const API_BASE = "/api/admin";
const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null;
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.

View File

@@ -86,3 +86,13 @@ export const require2FAOptions = () =>
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});
// Per-user 2FA enrollment status (users.totp_enabled) — NOT the company-wide
// require_2fa policy above. Mutations that enable/disable 2FA must invalidate
// the broad ["totp"] domain key.
export const totpStatusOptions = () =>
queryOptions({
queryKey: ["totp", "status"],
queryFn: () =>
jsonQuery<{ totp_enabled: boolean }>("/api/admin/totp/status"),
});

View File

@@ -1,5 +1,5 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface TripVehicle {
id: number;
@@ -59,7 +59,9 @@ export const tripListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<BackendTrip>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});
@@ -81,6 +83,8 @@ export const tripHistoryOptions = (filters: {
month?: string;
vehicleId?: number;
userId?: number;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
@@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: {
month: filters.month,
vehicleId: filters.vehicleId,
userId: filters.userId,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
@@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: {
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<BackendTrip>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});
export interface TripStats {
count: number;
total_km: number;
business_km: number;
private_km: number;
}
// Count/km totals over the WHOLE filtered set (not one page) — hits
// /trips/stats with the same filters the lists use. `month` accepts either a
// numeric month (paired with `year`, TripsAdmin style) or a combined
// "YYYY-MM" string (TripsHistory style); the server supports both.
export const tripStatsOptions = (filters: {
month?: number | string;
year?: number;
vehicleId?: number;
userId?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"stats",
{
month: filters.month,
year: filters.year,
vehicleId: filters.vehicleId,
userId: filters.userId,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<TripStats>(
`/api/admin/trips/stats${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useApiMutation } from "../lib/queries/mutations";
import { todayLocalStr } from "../utils/formatters";
import { combineDatetime } from "../utils/attendanceHelpers";
import {
Button,
Card,
@@ -41,6 +42,24 @@ interface CreateForm {
notes: string;
}
/**
* Wire payload for POST /attendance (CreateAttendanceSchema). The raw form
* above is NOT sent directly: its separate date+time inputs are combined into
* "YYYY-MM-DDTHH:MM:00" datetimes (same semantics as useAttendanceAdmin's
* create modal), and unset optional fields are null — never "".
*/
interface CreatePayload {
user_id: number;
shift_date: string;
leave_type: string;
notes: string | null;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
}
export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -52,7 +71,7 @@ export default function AttendanceCreate() {
}));
const [submitting, setSubmitting] = useState(false);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
@@ -88,7 +107,36 @@ export default function AttendanceCreate() {
setSubmitting(true);
try {
const result = await createMutation.mutateAsync(form);
const isLeave = form.leave_type !== "work";
const payload: CreatePayload = {
user_id: Number(form.user_id),
shift_date: form.shift_date,
leave_type: form.leave_type,
notes: form.notes || null,
};
if (isLeave) {
payload.leave_hours = form.leave_hours || 8;
} else {
payload.arrival_time = combineDatetime(
form.arrival_date,
form.arrival_time,
);
payload.departure_time = combineDatetime(
form.departure_date,
form.departure_time,
);
payload.break_start = combineDatetime(
form.break_start_date,
form.break_start_time,
);
payload.break_end = combineDatetime(
form.break_end_date,
form.break_end_time,
);
}
const result = await createMutation.mutateAsync(payload);
alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) {

View File

@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { totpStatusOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip, PageEnter } from "../ui";
@@ -86,9 +86,11 @@ export default function Dashboard() {
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
// Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide
// require_2fa policy flag (the banner below uses user.require2FA for that).
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
useQuery(totpStatusOptions());
const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
@@ -176,6 +178,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["totp"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
@@ -206,6 +209,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["totp"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });

View File

@@ -51,7 +51,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
import {
formatCurrency,
formatDate,
numberOr,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
@@ -686,7 +691,7 @@ export default function InvoiceDetail() {
tax_date: normalizeDateStr(inv.tax_date),
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
vat_rate: numberOr(inv.vat_rate, 21),
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
@@ -725,7 +730,11 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(inv.vat_rate) || 21,
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
// from the invoice rate corrupted mixed-rate invoices on re-save.
vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)),
}))
: [];
if (mappedItems.length > 0) {
@@ -781,8 +790,10 @@ export default function InvoiceDetail() {
// Pre-fill from order
if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data;
const vatRate =
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
const vatRate = numberOr(
order.vat_rate,
companySettings?.default_vat_rate ?? 21,
);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,

View File

@@ -44,7 +44,7 @@ import { jsonQuery } from "../lib/apiAdapter";
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
import { companySettingsOptions } from "../lib/queries/settings";
import { formatCurrency, todayLocalStr } from "../utils/formatters";
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
@@ -585,7 +585,7 @@ export default function IssuedOrderDetail() {
customer_name: d.customer_name ?? "",
currency: d.currency || "CZK",
apply_vat: d.apply_vat !== false,
vat_rate: Number(d.vat_rate) || 21,
vat_rate: numberOr(d.vat_rate, 21),
order_date: normalizeDateStr(d.order_date),
delivery_date: normalizeDateStr(d.delivery_date),
language: d.language || "cs",
@@ -605,10 +605,10 @@ export default function IssuedOrderDetail() {
id: it.id,
description: it.description || "",
item_description: it.item_description || "",
quantity: Number(it.quantity) || 1,
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
vat_rate: Number(it.vat_rate) || Number(d.vat_rate) || 21,
vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)),
}))
: [];
if (mapped.length > 0) setItems(mapped);
@@ -831,6 +831,12 @@ export default function IssuedOrderDetail() {
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
// Mirror the new status into the form (same as handleSubmit's success
// path): the one-shot hydration won't re-run (dataReady stays true), so
// without this the StatusChip, the `editable` flag and the delete-button
// gate would keep reading the stale status until a full remount. The PO
// number is repopulated by the detail-query sync effect.
setForm((prev) => ({ ...prev, status: newStatus }));
} catch (err) {
alert.error(err instanceof Error ? err.message : "Chyba připojení");
} finally {

View File

@@ -172,7 +172,13 @@ export default function Projects() {
enabled: showCreate,
});
const createMutation = useApiMutation<typeof createForm, { id: number }>({
const createMutation = useApiMutation<
Omit<typeof createForm, "start_date" | "end_date"> & {
start_date: string | null;
end_date: string | null;
},
{ id: number }
>({
url: () => `${API_BASE}/projects`,
method: () => "POST",
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
@@ -204,7 +210,13 @@ export default function Projects() {
setCreating(true);
try {
await createMutation.mutateAsync(createForm);
// Server's isoDateString.nullish() accepts null but rejects "" —
// send empty date fields as null so the create doesn't 400.
await createMutation.mutateAsync({
...createForm,
start_date: createForm.start_date || null,
end_date: createForm.end_date || null,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {

View File

@@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters";
import apiFetch from "../utils/api";
import {
tripListOptions,
tripStatsOptions,
tripVehiclesOptions,
type BackendTrip,
} from "../lib/queries/trips";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
@@ -158,12 +160,20 @@ export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
// "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch
// exactly those; the stat cards come from the server-side stats endpoint
// (computed over the whole filtered set, not one page).
const { items: trips, isPending: tripsLoading } =
usePaginatedQuery<BackendTrip>(tripListOptions({ perPage: 10 }));
const trips = tripsData ?? [];
// Stat cards are a personal "this month" summary — the header subtitle
// shows the current month, so the stats query is scoped to match it.
const now = new Date();
const { data: stats } = useQuery(
tripStatsOptions({ month: now.getMonth() + 1, year: now.getFullYear() }),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData ?? [];
const [showModal, setShowModal] = useState(false);
@@ -321,19 +331,14 @@ export default function Trips() {
return <LoadingState />;
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? t.end_km - t.start_km;
acc.count++;
acc.total += dist;
if (t.is_business) acc.business += dist;
else acc.private += dist;
return acc;
},
{ total: 0, business: 0, private: 0, count: 0 },
);
const totals = {
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
private: stats?.private_km ?? 0,
};
const recentTrips = trips.slice(0, 10);
const recentTrips = trips;
const columns: DataColumn<BackendTrip>[] = [
{

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
tripListOptions,
tripStatsOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripStats,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import { jsonQuery } from "../lib/apiAdapter";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { useApiMutation } from "../lib/queries/mutations";
@@ -28,6 +32,7 @@ import {
Select,
DateField,
MonthField,
Pagination,
StatusChip,
FilterBar,
LoadingState,
@@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip {
};
}
/**
* Escape user-origin strings before interpolating them into the print HTML
* (the JSX hidden-div approach escaped automatically; the string template
* must do it explicitly).
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const PRINT_STYLES = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
`;
/**
* Build the full print document from the fetched /trips/print data set —
* no hidden-div/innerHTML round-trip, so there is nothing to race against
* React's commit.
*/
function buildPrintHtml(opts: {
trips: Trip[];
totals: TripStats;
periodName: string;
companyName: string;
vehicleName: string | null;
userName: string | null;
logoUrl: string;
}): string {
const { trips, totals } = opts;
const rows = trips
.map(
(trip) => `
<tr>
<td>${formatDate(trip.trip_date)}</td>
<td>${escapeHtml(trip.driver_name)}</td>
<td>${escapeHtml(trip.spz)}</td>
<td>${escapeHtml(trip.route_from)} &rarr; ${escapeHtml(trip.route_to)}</td>
<td class="text-right">${formatKm(trip.start_km)} - ${formatKm(trip.end_km)}</td>
<td class="text-right"><strong>${formatKm(trip.distance)} km</strong></td>
<td class="text-center">
<span class="badge ${trip.is_business ? "badge-success" : "badge-warning"}">
${trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>${escapeHtml(trip.notes || "")}</td>
</tr>`,
)
.join("");
return `
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${escapeHtml(opts.periodName)}</title>
<style>${PRINT_STYLES}</style>
</head>
<body>
<div class="print-header">
<div class="print-header-left">
<img src="${opts.logoUrl}" alt="" class="print-logo" />
<div class="print-header-text">
<h1>KNIHA JÍZD</h1>
<div class="company">${escapeHtml(opts.companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(opts.periodName)}</div>
${opts.vehicleName ? `<div class="filters">Vozidlo: ${escapeHtml(opts.vehicleName)}</div>` : ""}
${opts.userName ? `<div class="filters">Řidič: ${escapeHtml(opts.userName)}</div>` : ""}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
</div>
<div class="summary">
<div class="summary-item">
<div class="summary-value">${totals.count}</div>
<div class="summary-label">Počet jízd</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.total_km)} km</div>
<div class="summary-label">Celkem</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.business_km)} km</div>
<div class="summary-label">Služební</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.private_km)} km</div>
<div class="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 70px">Datum</th>
<th style="width: 80px">Řidič</th>
<th style="width: 70px">Vozidlo</th>
<th>Trasa</th>
<th style="width: 70px" class="text-right">Stav km</th>
<th style="width: 60px" class="text-right">Vzdálenost</th>
<th style="width: 55px" class="text-center">Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="5" class="text-right">Celkem:</td>
<td class="text-right"><strong>${formatKm(totals.total_km)} km</strong></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
}
const PrintIcon = (
<svg
width="18"
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
});
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const printRef = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
}),
);
const trips = (tripsData ?? []).map(mapTrip);
// ONE shared filter object for the list, stats AND print requests so the
// three can never drift apart. An empty MonthField value yields
// month/year = undefined (omitted from the request), mirroring the
// `Number(...) || undefined` guard the queries always used.
const filters = {
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
};
const {
items: tripsItems,
pagination,
isPending,
} = usePaginatedQuery<BackendTrip>(tripListOptions({ ...filters, page }));
const trips = tripsItems.map(mapTrip);
// Stat cards: totals over the WHOLE filtered set (server-side aggregate),
// not just the visible page.
const { data: stats } = useQuery(tripStatsOptions(filters));
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
};
const getPeriodName = () =>
new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
filterPeriod
? new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
: "Všechna období";
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
return u?.name || null;
};
const handlePrint = () => {
const periodName = getPeriodName();
const handlePrint = async () => {
// Open the print window SYNCHRONOUSLY, inside the click's transient
// activation — calling window.open after the network await can fall
// outside the activation window and get popup-blocked (which previously
// failed silently).
const printWindow = window.open("", "_blank");
if (!printWindow) {
alert.error("Tisk byl zablokován prohlížečem");
return;
}
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${periodName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
}, 100);
// Fetch the FULL filtered set for the printout — the list query is
// paginated, so printing from it would truncate to the visible page.
let printTrips: Trip[];
let printTotals: TripStats;
try {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
`${API_BASE}/trips/print?${params.toString()}`,
);
printTrips = data.trips.map(mapTrip);
printTotals = data.totals;
} catch (e) {
printWindow.close();
console.error("Trip print data fetch failed:", e);
alert.error(e instanceof Error ? e.message : "Chyba připojení");
return;
}
// The user closed the popup while the data was loading — a deliberate
// cancel, not an error.
if (printWindow.closed) return;
// The document is built directly from the fetched data (no hidden-div /
// setTimeout round-trip), so it can be neither unmounted nor stale.
printWindow.document.write(
buildPrintHtml({
trips: printTrips,
totals: printTotals,
periodName: getPeriodName(),
companyName,
vehicleName: getSelectedVehicleName(),
userName: getSelectedUserName(),
logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
}),
);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
const calculateDistance = (): number => {
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
};
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
};
const columns: DataColumn<Trip>[] = [
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
>
<Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && (
{(pagination?.total ?? 0) > 0 && (
<Button
variant="outlined"
color="inherit"
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
<MonthField
value={filterPeriod}
onChange={(val) => {
setFilterPeriod(val);
setPage(1);
}}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterVehicleId}
onChange={setFilterVehicleId}
onChange={(value) => {
setFilterVehicleId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterUserId}
onChange={setFilterUserId}
onChange={(value) => {
setFilterUserId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všichni řidiči" },
...tripUsers.map((u) => ({
@@ -632,14 +811,21 @@ export default function TripsAdmin() {
{isPending ? (
<LoadingState />
) : (
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)}
</Card>
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: "none" }}>
<div className="print-header">
<div className="print-header-left">
<img
src="/api/admin/company-settings/logo?variant=light"
alt=""
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">{companyName}</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && (
<div className="filters">
Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: "70px" }} className="text-right">
Stav km
</th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>
{trip.route_from} &rarr; {trip.route_to}
</td>
<td className="text-right">
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</td>
<td className="text-right">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td className="text-center">
<span
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>{trip.notes || ""}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">
Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
</PageEnter>
);
}

View File

@@ -5,12 +5,19 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripHistoryOptions,
tripStatsOptions,
tripVehiclesOptions,
type BackendTrip,
} from "../lib/queries/trips";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
Card,
DataTable,
Select,
MonthField,
Pagination,
StatusChip,
PageHeader,
PageEnter,
@@ -91,18 +98,35 @@ export default function TripsHistory() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [page, setPage] = useState(1);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData;
const { data: tripsData, isPending } = useQuery(
const {
items: tripsData,
pagination,
isPending,
} = usePaginatedQuery<BackendTrip>(
tripHistoryOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
page,
}),
);
// Stat cards: month totals over the WHOLE filtered set (server-side
// aggregate over the same filters), not just the visible page.
const { data: stats } = useQuery(
tripStatsOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
}),
);
const trips: Trip[] = (tripsData ?? []).map((t) => ({
const trips: Trip[] = tripsData.map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
@@ -118,14 +142,11 @@ export default function TripsHistory() {
notes: t.notes ?? undefined,
}));
const totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? t.distance || 0 : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 },
);
const totals = {
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
};
if (!hasPermission("trips.history")) return <Forbidden />;
@@ -221,12 +242,21 @@ export default function TripsHistory() {
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={month} onChange={(val) => setMonth(val)} />
<MonthField
value={month}
onChange={(val) => {
setMonth(val);
setPage(1);
}}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={vehicleId}
onChange={(value) => setVehicleId(value)}
onChange={(value) => {
setVehicleId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
@@ -275,14 +305,21 @@ export default function TripsHistory() {
{isPending ? (
<LoadingState />
) : (
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)}
</Card>
</PageEnter>

View File

@@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem {
const API_BASE = "/api/admin/warehouse/items";
// Must match UnitEnum in src/schemas/warehouse.schema.ts — the server rejects
// any other value (CreateItemSchema/UpdateItemSchema), so free text here would
// make the save 400.
const UNIT_OPTIONS = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const;
interface ItemForm {
item_number: string;
name: string;
@@ -449,15 +454,14 @@ export default function WarehouseItemDetail() {
</Select>
</Field>
<Field label="Jednotka" required error={errors.unit}>
<TextField
<Select
value={form.unit}
error={!!errors.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
onChange={(v) => {
updateForm("unit", v);
setErrors((prev) => ({ ...prev, unit: "" }));
}}
placeholder="ks, m, kg..."
fullWidth
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
/>
</Field>
<Field

View File

@@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { apiFetch } from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseReceiptDetailOptions,
@@ -161,6 +163,33 @@ export default function WarehouseReceiptDetail() {
}
};
// A plain <a href> cannot send the Authorization header, so attachments are
// fetched via apiFetch and handed to the browser as a temporary blob URL.
const handleDownloadAttachment = async (att: WarehouseReceiptAttachment) => {
if (!receipt) return;
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receipt.id}/attachments/${att.id}`,
);
if (!response.ok) {
const result = await response.json().catch(() => ({}));
alert.error(result.error || "Nepodařilo se stáhnout přílohu");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = att.file_name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
@@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() {
width: "45%",
bold: true,
render: (att) => (
<a
href={`/api/admin/warehouse/receipts/${r.id}/attachments/${att.id}`}
target="_blank"
rel="noopener noreferrer"
<Link
component="button"
type="button"
onClick={() => handleDownloadAttachment(att)}
sx={{ textAlign: "left", font: "inherit" }}
>
{att.file_name}
</a>
</Link>
),
},
{

View File

@@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => {
return extractTime(datetime);
};
/**
* Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the
* combined local-datetime wire format the attendance API expects
* ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString`
* and parsed with `new Date(...)` as local time). Returns null when either
* part is unset — "no value" for the optional datetime fields.
*/
export const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null;
export const calcProjectMinutesTotal = (
logs: Array<{
project_id?: number;

View File

@@ -50,6 +50,18 @@ export function todayLocalStr(): string {
return `${d.getFullYear()}-${m}-${day}`;
}
/**
* Coerce a server-returned numeric (number | decimal-string | null/undefined)
* to a number, falling back ONLY when the value is missing or not numeric —
* NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The
* `Number(x) || fallback` idiom silently turns stored zeros into the fallback.
*/
export function numberOr(raw: unknown, fallback: number): number {
if (raw == null || raw === "") return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
export function formatKm(km: number | string): string {
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
}

View File

@@ -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,

View File

@@ -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" } },

View File

@@ -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);
},
);

View File

@@ -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);
},
);

View File

@@ -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({

View File

@@ -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,

View File

@@ -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",

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import {
intIdFromForm,
nullableDateTimeString,
nullableIntIdFromForm,
numberFromForm,
numberInRange,
@@ -66,19 +67,25 @@ export const AttendancePunchSchema = z.object({
address: z.string().max(500).nullish(),
});
// arrival/departure/break fields travel as COMBINED local datetimes
// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a
// date + time input) — the service parses them with `new Date(...)`. They are
// NOT bare HH:MM times.
export const CreateAttendanceSchema = z.object({
user_id: intIdFromForm.optional(),
shift_date: z.string(),
arrival_time: timeString.nullish(),
arrival_time: nullableDateTimeString.optional(),
arrival_lat: numberFromForm.nullish(),
arrival_lng: numberFromForm.nullish(),
arrival_accuracy: numberFromForm.nullish(),
arrival_address: z.string().nullish(),
departure_time: timeString.nullish(),
departure_time: nullableDateTimeString.optional(),
departure_lat: numberFromForm.nullish(),
departure_lng: numberFromForm.nullish(),
departure_accuracy: numberFromForm.nullish(),
departure_address: z.string().nullish(),
break_start: nullableDateTimeString.optional(),
break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(),
project_id: nullableIntIdFromForm.nullish(),
leave_type: z
@@ -90,10 +97,10 @@ export const CreateAttendanceSchema = z.object({
});
export const UpdateAttendanceSchema = z.object({
arrival_time: timeString.nullish(),
departure_time: timeString.nullish(),
break_start: timeString.nullish(),
break_end: timeString.nullish(),
arrival_time: nullableDateTimeString.optional(),
departure_time: nullableDateTimeString.optional(),
break_start: nullableDateTimeString.optional(),
break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(),
project_id: nullableIntIdFromForm.optional(),
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),

View File

@@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
.max(64, "Záložní kód je příliš dlouhý"),
remember_me: z.boolean().optional().default(false),
});
export const TotpEnableSchema = z.object({

View File

@@ -111,6 +111,40 @@ export const timeString = z.preprocess(
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
);
/**
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
* attendance forms produce by joining a date input and a time input
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
* stripped before validating), because the client appends ":00" and an edit
* form round-trips a value the `Date.toJSON` override serialised as
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
*/
export const dateTimeString = z.preprocess(
(v) =>
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
? v.slice(0, 16)
: v,
z
.string()
.regex(
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
),
);
/**
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
* forms historically submitted empty strings for unfilled time fields, and a
* hard reject would 400 the whole form — including leave records that have no
* times at all). Pair with `.optional()` at the call site for fields that may
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
*/
export const nullableDateTimeString = z.preprocess(
(v) => (v === "" || v === null ? null : v),
z.union([z.null(), dateTimeString]),
);
/**
* An email field that also accepts an empty string (meaning "not set"). Many
* settings/supplier forms submit "" for un-filled optional emails; a bare

View File

@@ -54,6 +54,7 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().max(255).nullish(),
bank_account: z.string().max(255).nullish(),
issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(),
customer_id: nullableIntIdFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),

View File

@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
});
export const UpdateTripSchema = z.object({
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
vehicle_id: intIdFromForm.optional(),
trip_date: isoDateString.optional(),
start_km: nonNegativeNumberFromForm.optional(),
end_km: nonNegativeNumberFromForm.optional(),

View File

@@ -126,6 +126,8 @@ export interface CreateAttendanceData {
departure_lng?: number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
leave_type: string;
@@ -1608,6 +1610,8 @@ export async function createAttendance(
departure_lng: data.departure_lng ?? null,
departure_accuracy: data.departure_accuracy ?? null,
departure_address: data.departure_address ?? null,
break_start: data.break_start ? new Date(data.break_start) : null,
break_end: data.break_end ? new Date(data.break_end) : null,
notes: data.notes ?? null,
project_id: data.project_id ?? null,
leave_type: data.leave_type as attendance_leave_type,

View File

@@ -430,6 +430,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
export async function getOrderDataForInvoice(orderId: number) {
const order = await prisma.orders.findUnique({
where: { id: orderId },
// This result is spread into the order-data API response — never include
// the PO attachment blob (served only by GET /orders/:id/attachment).
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },

View File

@@ -206,8 +206,16 @@ async function releaseSequence(
/** Verify a shared number is not already used by an order or project. */
export async function isSharedNumberTaken(number: string): Promise<boolean> {
const [existingOrder, existingProject] = await Promise.all([
prisma.orders.findFirst({ where: { order_number: number } }),
prisma.projects.findFirst({ where: { project_number: number } }),
// select id only — the orders row carries the PO attachment blob, which an
// existence check must never pull.
prisma.orders.findFirst({
where: { order_number: number },
select: { id: true },
}),
prisma.projects.findFirst({
where: { project_number: number },
select: { id: true },
}),
]);
return !!(existingOrder || existingProject);
}
@@ -238,8 +246,11 @@ export async function isOfferNumberTaken(number: string): Promise<boolean> {
/** Verify an order number is not already used. */
export async function isOrderNumberTaken(number: string): Promise<boolean> {
// select id only — the orders row carries the PO attachment blob, which an
// existence check must never pull.
const existing = await prisma.orders.findFirst({
where: { order_number: number },
select: { id: true },
});
return !!existing;
}

View File

@@ -72,6 +72,10 @@ async function syncProjectStatus(
});
}
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat,
// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW
// order/item field here, add it to that select — a field missing from the
// select is silently 0 in the per-currency totals.
function enrichOrder(o: any) {
const subtotal = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
@@ -158,6 +162,11 @@ export async function listOrders(params: ListOrdersParams) {
take: limit,
// Tiebreak on id so same-second created_at rows sort deterministically.
orderBy: [{ [sortField]: order }, { id: order }],
// The PO attachment blob is served ONLY by GET /:id/attachment. Never
// pull it into list payloads — a single 1MB PDF would serialize into
// megabytes of JSON per row. attachment_name stays as the existence
// signal the frontend uses.
omit: { attachment_data: true },
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: "asc" } },
@@ -193,17 +202,21 @@ export async function getOrderTotals(
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOrderWhere(params);
// Select ONLY what the math needs (currency + VAT flags + item numbers).
// This runs over the WHOLE filtered set, so pulling attachment blobs,
// sections HTML or relations here would multiply the query size for nothing.
const orders = await prisma.orders.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: "asc" } },
order_sections: { orderBy: { position: "asc" } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: {
select: { id: true, invoice_number: true },
take: 1,
orderBy: { id: "desc" },
select: {
currency: true,
apply_vat: true,
vat_rate: true,
order_items: {
select: {
quantity: true,
unit_price: true,
is_included_in_total: true,
},
},
},
});
@@ -228,6 +241,9 @@ export async function getOrderTotals(
export async function getOrder(id: number) {
const order = await prisma.orders.findUnique({
where: { id },
// The blob belongs only to GET /:id/attachment — the detail payload
// carries attachment_name as the existence signal.
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
@@ -567,7 +583,11 @@ interface UpdateOrderData {
}
export async function updateOrder(id: number, body: UpdateOrderData) {
const existing = await prisma.orders.findUnique({ where: { id } });
// Pre-read only the fields the guards below check — never the PDF blob.
const existing = await prisma.orders.findUnique({
where: { id },
select: { status: true, order_number: true },
});
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
@@ -680,7 +700,11 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
}
export async function deleteOrder(id: number, deleteFiles = false) {
const existing = await prisma.orders.findUnique({ where: { id } });
// Pre-read only what the delete needs (number release + year) — not the blob.
const existing = await prisma.orders.findUnique({
where: { id },
select: { order_number: true, created_at: true },
});
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;

View File

@@ -0,0 +1,28 @@
/**
* Build a Content-Disposition header value that is safe for non-ASCII
* filenames (Czech diacritics are the norm here: "příloha č. 1.pdf").
*
* Node's HTTP layer THROWS on header values containing characters above
* U+00FF, so the raw filename must never be interpolated into the header
* directly — that turns a download of a diacritic-named file into a 500.
* Per RFC 5987 / RFC 6266 the real UTF-8 name is carried in the `filename*`
* parameter (percent-encoded), while `filename` holds an ASCII-only fallback
* for legacy clients. Modern browsers prefer `filename*`.
*/
export function contentDisposition(
type: "inline" | "attachment",
fileName: string,
): string {
const asciiFallback = fileName
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "");
// encodeURIComponent leaves ' ( ) * raw, but they are not RFC 5987
// attr-chars (the apostrophe is even the ext-value delimiter) — strict
// parsers would reject the filename* and fall back to the mangled ASCII
// name. Percent-encode them like the `content-disposition` npm package.
const encoded = encodeURIComponent(fileName).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}