Files
app/AUDIT-2026-06-12.md
BOHA e11765bf0e fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
  failed deficit line rolls back the surplus corrective receipt — retries
  no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
  of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
  (in-transaction, clamped at 0)

High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
  the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
  when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
  (Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
  hours against its own balance (mirrors createLeave)

Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
  500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
  Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
  (recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
  @db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
  /items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
  finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
  settings.company|settings.system; Settings System tab no longer clobbers
  Firma numbering patterns; draft invoices hide the dead PDF button;
  dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
  audit-log + invoice month buckets day-shift fixes

Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.

~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:19 +02:00

45 KiB
Raw Blame History

Codebase Audit — boha-app-ts — 2026-06-12

Summary

Severity Count
Critical 3
High 6
Medium 11
Low 8
Total 28

2026-06-12: M3 (offer PDF footer) withdrawn as a false positive after user verification — see the struck section below. Counts updated 12→11 / 29→28.

Methodology

  • Fan-out discovery: 27 independent finder passes swept the codebase across domain modules (auth/sessions, attendance/leave, documents — offers/orders/issued-orders/invoices, warehouse, projects, trips/vehicles, plan, users/roles, AI/Odin, settings) and cross-cutting dimensions (Zod-cap-vs-DB-column alignment, date/timezone boundaries, pagination determinism, React Query cache invalidation, transaction atomicity, FK-error→4xx mapping, privilege boundaries).
  • Dedupe vs prior backlog: candidate findings were de-duplicated against the existing audit backlog (docs/codebase-audit-findings-2026-06-06.md, docs/cross-module-consistency-audit-2026-06-09.md, REVIEW_FINDINGS.md). Already-fixed items (e.g. the 2026-06-06 sessions audit-logging finding) and already-tracked duplicates were dropped; only net-new defects survived.
  • 3-lens adversarial verification: every surviving finding was independently re-checked through three lenses — (1) trace-to-refute (follow the full code path end-to-end and try to break each step), (2) convention veto (confirm it genuinely VIOLATES a documented CLAUDE.md convention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-time toJSON, local-noon @db.Date writes, lenient date schemas, net-only documents, gross received_invoices.amount, shared orders.* permission family), and (3) executable repro (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independent real verdicts. Findings the convention lens vetoed (e.g. one warehouse over-issue variant whose exact numbers are blocked by an upstream aggregate guard — kept only after a corrected, reachable repro was supplied) were re-scoped, not waved through.

Order below is by severity, then by blast radius within a severity.


CRITICAL

C1. Deleting a vacation/sick attendance record never restores the leave balance — vacation_used stays permanently inflated

  • Severity: Critical
  • File: src/services/attendance.service.ts:1751
  • Failure scenario: createLeave increments leave_balances.vacation_used/sick_used when booking leave (lines 13041331), but deleteAttendance (17511772) only runs prisma.attendance.delete — it never decrements the balance. getStatus (line 246) and getBalances (line 530) derive vacation_remaining = vacation_total - vacation_used from the stored counter, so cancelling a booked leave by deleting its rows permanently removes entitlement the employee never actually took. The leave-request approval path (leave-requests.ts) has the identical asymmetric increment-on-approve / no-decrement-on-delete.
  • Repro:
    1. As an admin (attendance.manage), POST /api/admin/attendance?action=leave for user 5, date_from=2026-07-06, date_to=2026-07-10, leave_type=vacation, leave_hours=8 → creates 5 rows and adds 40h to vacation_used.
    2. Read balances: vacation_used=40, vacation_remaining = total-40.
    3. DELETE /api/admin/attendance/:id for each of the 5 rows.
    4. Read balances again: rows are gone but vacation_used is still 40 and vacation_remaining is still down 40h. Only a manual admin handleBalances reset fixes it.
  • Pinning test: Book a 5-day vacation, assert vacation_used rose by 40h, delete all 5 attendance rows, then assert vacation_used returned to its pre-booking value (fails today: stays 40).

C2. confirmIssue: duplicate issue lines on the same batch pass per-line validation but decrement cumulatively, driving batch quantity negative (silent over-issue)

  • Severity: Critical
  • File: src/services/warehouse.service.ts:626
  • Failure scenario: The confirm path validates and decrements stock per line, re-reading the batch fresh each iteration with no cross-line running tally and no if (newBatchQty < 0) throw guard. Two issue lines for the same item that resolve to the same batch each pass the independent batch.quantity >= line.quantity check, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes 3-5 = -2, flagging is_consumed=true. The negative/consumed batch drops out of getItemTotalStock (is_consumed:false filter), so the over-issue is silently hidden from all stock totals. No (issue_id, batch_id) unique constraint exists on sklad_issue_lines.
  • Repro (corrected — the single-batch case is blocked by the per-item aggregate guard in validateIssueReservationRules; use two batches so the per-item total check passes while one batch still goes negative):
    1. Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16.
    2. POST /issues with two lines, both item_id=X, no batch_id, each quantity=5. selectFifoBatches is called once per line against the live DB with nothing persisted, so both resolve to B1; the per-item availability check (16 < 10 is false) passes. Draft stores two lines both batch_id=B1.
    3. POST /issues/:id/confirm → 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to -2 with is_consumed=true.
    4. 10 units issued from a batch that held 8; B1 persists at quantity=-2, drops from stock totals — 2 units leave with no accounting trail and FIFO is broken, with no error raised.
  • Pinning test: Confirm an issue containing two same-item lines whose combined quantity exceeds a single resolved batch; assert the confirm is rejected (400) and no batch row is left with quantity < 0.

C3. confirmInventorySession swallows an in-transaction throw with return instead of throw, committing partial stock writes the route reports as failed

  • Severity: Critical
  • File: src/services/warehouse.service.ts:1275
  • Failure scenario: The function body is return prisma.$transaction(async (tx) => { ... }) (line 1108). A single loop over session.items interleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upserts sklad_item_locations; a later deficit line makes selectFifoBatches throw. The catch at line 1275 returns { error, status: 400 } from inside the transaction callback. In an interactive Prisma transaction, returning a value (rather than throwing) commits — so the surplus item's writes persist while the session→CONFIRMED update (line 1293) is skipped, leaving the session DRAFT. The route sees "error" in result and returns HTTP 400 ("nothing changed"). Re-confirming the still-DRAFT session re-runs the loop and re-creates the same surplus batch — repeatable phantom inventory and corrupted valuation on every retry.
  • Repro:
    1. ItemA exists; ItemB has a location row quantity=10 but fewer unconsumed batches (documented location/batch drift at lines 12441250 — also reproducible by consuming batches between session create and confirm).
    2. POST /admin/inventory-sessions with items ordered [{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}] (ItemA's line gets the lower id, processed first).
    3. POST /admin/inventory-sessions/<id>/confirm → HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumped item_location, and the session is still DRAFT.
    4. Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure.
  • Pinning test: Confirm an inventory session whose item list has a valid surplus line before an over-deficit line; assert the response is an error AND no sklad_receipts/sklad_batches rows were created (transaction rolled back) and the session is unchanged.

HIGH

H1. Terminating one session force-logs-out ALL of the user's sessions on the terminated device's next refresh

  • Severity: High
  • File: src/services/auth.ts:280
  • Failure scenario: Session termination (sessions.ts:94-97 DELETE /:id, sessions.ts:124-131 action=all, profile.ts:98-101 password-change) sets ONLY replaced_at (no replaced_by_hash, row not deleted). Legitimate rotation (auth.ts:315-318) sets BOTH. The reuse-detection condition at auth.ts:280-282 is storedToken.replaced_at || storedToken.replaced_by_hash, evaluated BEFORE the expiry check at line 292 — so a manually-terminated token presented on a later refresh is misclassified as theft and runs tx.refresh_tokens.deleteMany({ where: { user_id } }) (line 285), wiping every session for that user, including the laptop the user is actively on, plus a false "reuse detected" warning.
  • Repro:
    1. Log in from two devices → Session A (laptop), Session B (phone), both replaced_at=null.
    2. From the laptop, DELETE /api/admin/sessions/<B.id> → B gets replaced_at set only.
    3. After B's access token expires (~15 min), the phone calls POST /api/admin/refresh with B's still-present cookie.
    4. refreshAccessToken hits the reuse branch (B's replaced_at truthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh.
  • Pinning test: Create two refresh-token sessions, terminate one via the sessions DELETE route, present the terminated token to refreshAccessToken; assert the OTHER session's row still exists (fails today: it is deleted).

H2. order_items.description Zod cap (8000) far exceeds DB column VarChar(500) → Prisma 500 on long descriptions

  • Severity: High
  • File: src/schemas/orders.schema.ts:12
  • Failure scenario: OrderItemSchema.description = z.string().max(8000) but order_items.description is @db.VarChar(500) (schema.prisma:327). createOrder/updateOrder map the value straight into tx.order_items.createMany with no truncation. A 5018000 char description passes Zod, overflows the column under MySQL strict mode (P2000), and createOrder's catch only re-maps errors carrying a status property — so it surfaces as HTTP 500 "Interní chyba serveru" instead of a clean Czech 400, with the whole order transaction rolled back.
  • Repro: POST /api/admin/orders (Content-Type application/json so the manual-JSON branch runs), items:[{ description: "x".repeat(600), quantity:1, unit_price:100 }] → HTTP 500 instead of 400; no order persisted. Same on PUT /api/admin/orders/:id for an order in prijata/v_realizaci.
  • Pinning test: parseBody(OrderItemSchema, { description: "x".repeat(600), ... }) should fail Zod with a 400-class error (fails today: passes, then 500s at Prisma). Fix: .max(500).

H3. Issued-order status transition silently discards unsaved item/section edits and marks them clean

  • Severity: High
  • File: src/admin/pages/IssuedOrderDetail.tsx:524
  • Failure scenario: A sent issued order is both editable AND has valid transitions. handleStatusChange (line 516) sends ONLY { status: newStatus }; the server's replaceItems = editable && Array.isArray(body.items) is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 calls markClean({ form, items, sections }) with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard so isDirty flips false and the beforeunload warning never fires. After the transition the order is confirmed → read-only, so the lost edits cannot be re-applied. (Contrast: OfferDetail/InvoiceDetail do NOT call markClean on transition.)
  • Repro: Open a sent issued order with orders.edit, edit a line item / section / supplier (form dirty), click "Potvrdit" and confirm. The transition succeeds, the edits are silently dropped, and on refetch the items/sections revert to stored values with no warning.
  • Pinning test: Render IssuedOrderDetail for a sent order, mutate an item, invoke a status transition; assert the PUT payload includes the edited items (or that the guard stays dirty / a warning is shown) — fails today.

H4. Received-invoices list is paginated server-side (25/page) but the UI sends no page param and renders no Pagination control — rows 26+ are permanently hidden

  • Severity: High
  • File: src/admin/pages/ReceivedInvoices.tsx:265
  • Failure scenario: receivedInvoiceListOptions hits /received-invoices, which applies skip/take with parsePagination's default limit of 25. The component builds the query with no page/perPage, reads only listQuery.data?.data (the first 25 rows), and renders NO <Pagination>. The "Celkem" footer is sourced from the separate /received-invoices/list-totals over the FULL filtered set, so with 26+ rows the displayed total visibly disagrees with the on-screen rows. The sibling issued-invoices tab (Invoices.tsx) correctly uses usePaginatedQuery + <Pagination>.
  • Repro: Seed 26 CZK received invoices in one month. GET /received-invoices?month=6&year=2026 returns data.length=25, pagination.total=26; GET /received-invoices/list-totals?... returns 26000 CZK. In the UI (Faktury → Přijaté, June 2026) the table shows 25 rows summing to 25000 while the footer shows 26000 — and the 26th invoice is unreachable.
  • Pinning test: With 26 received invoices in a month, assert the page requests page 2 (or renders a pager) and that all 26 are reachable — fails today.

H5. Odin invoice import: Czech-format dates silently corrupt month/year (wrong month or NaN) in received_invoices

  • Severity: High
  • File: src/admin/components/odin/InvoiceReviewCard.tsx:79
  • Failure scenario: The review card exposes "Datum vystavení"/"Datum splatnosti" as free-text fields; OdinChat.saveInvoice sends the raw string. The multipart schema validates issue_date only as z.string().max(255).nullish(), so it passes. The route derives invoiceMonth = new Date(issue_date).getMonth()+1 / getFullYear() with no validity guard. new Date("15.03.2026") → Invalid Date → NaN (day>12); new Date("12.6.2026") → December 2026 (wrong month). NAS save (line 410) runs BEFORE the DB create (line 420), so for the NaN case Prisma rejects into the UnsignedTinyInt/UnsignedSmallInt columns → HTTP 500 with an orphaned NAS file; for day≤12 it silently files under the wrong month/year.
  • Repro: In Odin, edit an extracted invoice's issue date to 15.03.2026 and Save → 500 + orphaned NAS file. Use 12.6.2026 → invoice silently filed under month 12. Equivalent: POST /api/admin/received-invoices (multipart) with issue_date:"15.03.2026".
  • Pinning test: POST /received-invoices with issue_date="15.03.2026" should return a 400 (or normalize) — fails today (500 + NaN write). Fix: use isoDateString coercion / validity guard.

H6. Leave-request approval charges vacation/sick balance for Czech public holidays that fall inside the range

  • Severity: High
  • File: src/routes/admin/leave-requests.ts:222
  • Failure scenario: Both the request-creation loop (lines 9097) and the approval loop (lines 222244) count business days with a weekend-only filter (dow !== 0 && dow !== 6); the file never imports isHoliday. So weekday Czech public holidays inside the range are counted as charged leave days, and approval increments vacation_used/sick_used by totalBusinessDays * 8 including them. The sibling attendance.service.createLeave (line 1270) correctly skips isHoliday(...), and getBusinessDaysInMonth excludes holidays from the work fund — so the deduction is a double charge for a day already paid/free.
  • Repro: Employee POST /api/admin/leave-requests {leave_type:"vacation", date_from:"2026-05-01", date_to:"2026-05-09"} (1.5. and 8.5. are weekday holidays in 2026) → total_hours=48. Approver PUT /.../{id} {status:"approved"}vacation_used grows by 48h instead of 32h; holiday dates are recorded as consumed vacation in attendance.
  • Pinning test: Approve a vacation request spanning a weekday public holiday; assert vacation_used increased only by the non-holiday business hours (fails today: includes the holiday).

MEDIUM

M1. Invoice date fields accept arbitrary strings and write a day early to @db.Date when a datetime value is submitted

  • Severity: Medium
  • File: src/services/invoices.service.ts:533
  • Failure scenario: CreateInvoiceSchema/UpdateInvoiceSchema type issue_date/due_date/tax_date/paid_date as plain z.string().max(255).nullish() (not isoDateString). createInvoice/updateInvoice do new Date(String(...)) straight into the @db.Date columns. A round-tripped API value like "2025-03-15T00:00:00" (the toJSON override emits local time, no Z) parses as local Prague → 2025-03-14 22:00 UTC → Prisma truncates to 2025-03-14. The invoice then also lands in the wrong month bucket in getInvoiceStats/buildInvoiceWhere. Every other @db.Date-writing module uses isoDateString (which slices the time off); invoices is the lone outlier.
  • Repro: POST /api/admin/invoices with issue_date:"2025-03-01T00:00:00" → DB stores 2025-02-28; GET .../stats?month=3&year=2025 excludes it and inflates February.
  • Pinning test: Create an invoice with issue_date="2025-03-01T00:00:00"; assert the stored date is 2025-03-01 (fails today: 2025-02-28). Fix: isoDateString.nullish().

M2. order_items.unit Zod cap (255) exceeds DB column VarChar(20) → Prisma 500 on long unit strings

  • Severity: Medium
  • File: src/schemas/orders.schema.ts:15
  • Failure scenario: unit: z.string().max(255) vs order_items.unit @db.VarChar(20) (schema.prisma:330). A 21255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-maps status-bearing errors; update has no try/catch). The sibling offers/issued-orders schemas correctly use .max(20).
  • Repro: POST /api/admin/orders (JSON) items:[{ description:"Test", quantity:1, unit_price:100, unit:"abcdefghijklmnopqrstuvwxyz" }] → HTTP 500 instead of 400. (The MUI form enforces maxLength:20 client-side, so this is an API/non-browser vector.)
  • Pinning test: parseBody(OrderItemSchema, { unit: "x".repeat(21), ... }) should 400 at Zod — fails today. Fix: .max(20).
  • Status: Withdrawn — user verified the footer DOES render in production.
  • Why the audit got it wrong: the finding (and all three verification lenses) trusted the repo's own documentation — the html-to-pdf.ts:68-74 comment and the CLAUDE.md line "Chromium has no CSS margin-box footers". That fact has been stale since Chrome 131 (Nov 2024), which implemented CSS @page margin boxes; the project runs Puppeteer 24.40 (bundled Chrome 146 locally) and prod renders with system Chromium 149, both far past 131, so the offer's @bottom-center { counter(page) } footer renders fine. No lens rendered an actual PDF — documentation poisoning, not a code bug.
  • Real follow-up (doc-only, Low): correct the outdated claim in html-to-pdf.ts and CLAUDE.md so future work doesn't repeat this; the footerTemplate mechanism and the CSS margin-box mechanism are now BOTH valid, coexisting approaches.

M4. customer_order_number Zod cap (255) exceeds DB column VarChar(100) → Prisma 500

  • Severity: Medium
  • File: src/schemas/orders.schema.ts:54
  • Failure scenario: customer_order_number: z.string().max(255) vs orders.customer_order_number @db.VarChar(100) (schema.prisma:356). A 101255 char value passes Zod, overflows the column (P2000 under strict mode), and surfaces as 500 instead of 400 (no P2003/P2000 mapping; create catch only handles status-bearing errors).
  • Repro: POST /api/admin/orders (JSON) { customer_order_number: "A".repeat(150), currency:"CZK", language:"cs", status:"prijata" } → HTTP 500; no order created. Same on PUT /api/admin/orders/:id.
  • Pinning test: parseBody(CreateOrderSchema, { customer_order_number: "A".repeat(150), ... }) should 400 — fails today. Fix: .max(100) on both schemas.

M5. Foreign-currency invoice PDF returns HTTP 500 (and fails to archive) when the CNB rate lookup throws

  • Severity: Medium
  • File: src/routes/admin/invoices-pdf.ts:411
  • Failure scenario: For a EUR invoice, const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0 is unguarded. When CNB is unreachable and no cached entry exists for the issue date, getRate throws "Nepodařilo se získat aktuální kurzy z ČNB", which propagates to the route's catch and returns the 500 error page — blocking BOTH preview and NAS archival, even though the rate only feeds the cosmetic CZK-conversion footnote. Sibling consumers (received-invoices stats, ai-tools) wrap toCzk and degrade per-row; the PDF path has no fallback.
  • Repro: EUR invoice with a back-dated/never-fetched issue date, CNB unreachable → GET /api/admin/invoices-pdf/:id (preview) or ?save=1 (archive) returns 500; the save=1 archival is silently swallowed client-side, leaving no NAS copy.
  • Pinning test: Mock getRate to throw, render a foreign-currency invoice PDF; assert a non-error response (PDF rendered with the conversion line omitted) — fails today (500).

M6. updateProject sets FK fields with no existence check → FK violation 500

  • Severity: Medium
  • File: src/services/projects.service.ts:115
  • Failure scenario: updateProject writes customer_id/responsible_user_id/quotation_id/order_id straight into prisma.projects.update with only int coercion and no findUnique. The FKs are onDelete: Restrict, so a non-existent id raises P2003 → generic 500. createProject validates customer_id/responsible_user_id and returns clean Czech 400s; the update path is the inconsistent gap.
  • Repro: PUT /api/admin/projects/1 { "customer_id": 999999 } → HTTP 500 "Interní chyba serveru" instead of "Zákazník nenalezen" 400. Same for the other three FK fields.
  • Pinning test: PUT /projects/:id with a non-existent customer_id; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirror createProject's existence checks.

M7. Date-range filters/reports exclude the whole date_to day (lte against UTC-midnight) — inclusive end date silently drops same-day records

  • Severity: Medium
  • File: src/routes/admin/warehouse.ts:1038
  • Failure scenario: Receipts/issues filters and the project-consumption / movement-log reports build created_at: { lte: new Date(date_to) }. new Date("2026-06-12") parses as UTC midnight = 02:00 Prague; created_at is @db.DateTime(0) storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is > 00:00Z and excluded. The entire requested end day disappears from filtered lists and report totals.
  • Repro: Confirm a receipt today at 09:00 Prague. GET /admin/warehouse/receipts?date_from=2026-06-12&date_to=2026-06-12 returns zero rows. Reports for a period ending 2026-06-12 under-report the last day.
  • Pinning test: Create a same-day receipt at 09:00 local, filter date_to = that date; assert the receipt is returned — fails today. Fix: half-open lt utcMidnightOfLocalDay(date_to + 1 day).

M8. POST/PUT receipts & issues 500 on nonexistent or deleted FK ids instead of a clean Czech 400

  • Severity: Medium
  • File: src/routes/admin/warehouse.ts:1117
  • Failure scenario: The receipts/issues create+update handlers write rows directly with no FK-existence pre-check and no try/catch; schemas only int-coerce ids. A deleted/nonexistent supplier_id/location_id/item_id/project_id/batch_id raises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) and trips.ts all pre-validate FK existence precisely to avoid this; warehouse omits the guard.
  • Repro: POST /receipts { items:[{ item_id: 99999999, quantity:1, unit_price:10 }] } → 500. POST /issues { project_id: 99999999, items:[{ item_id:<in-stock>, quantity:1 }] } → 500. POST /issues with a valid item + nonexistent explicit batch_id → 500.
  • Pinning test: POST /receipts with a nonexistent item_id; assert a 400 ("Položka nenalezena") — fails today (500).

M9. updateEntry never re-checks the per-cell cap, so expanding an entry's range pushes a day past MAX_RECORDS_PER_CELL and silently hides existing records

  • Severity: Medium
  • File: src/services/plan.service.ts:597
  • Failure scenario: createEntry/bulkCreateEntries/createOverride all guard the per-cell cap of 3 via assertEntryCapAvailable, but updateEntry does not. Expanding an entry's date_from/date_to onto a day that already holds 3 active entries succeeds, producing 4. resolveCell/resolveGrid cap display at 3 (newest-first), so the oldest entry silently vanishes from the grid while remaining active in the DB.
  • Repro: Create 3 active entries on 2099-07-01 for user 5; create a 4th on 2099-07-02; PATCH /plan/entries/<4th> { date_from:"2099-07-01", date_to:"2099-07-01" } → 200; the grid for that day now shows only 3 of the 4 entries.
  • Pinning test: Fill a day to the cap, then PATCH another entry to overlap that day; assert the update is rejected with the cap error — fails today.

M10. POST /users does not strip role_id for non-admins — a users.create holder can grant any non-admin role

  • Severity: Medium
  • File: src/routes/admin/users.ts:63
  • Failure scenario: The POST handler forwards role_id straight to createUser; createUser only rejects the literal admin role. The PUT handler explicitly strips role_id/is_active for non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: a users.create holder can mint an account on any high-privilege non-admin role (e.g. one bundling settings.roles or users.edit) the caller itself lacks. Violates the documented "Role-management writes are admin-only and re-checked" rule.
  • Repro: As account M with only users.create, POST /api/admin/users { ..., role_id: N } where N is a non-admin role bundling settings.roles/users.edit → 201; the new account holds permissions M never had.
  • Pinning test: As a non-admin users.create caller, POST a user with a privileged role_id; assert the created user's role is NOT the privileged one (role stripped, as on PUT) — fails today.

M11. route_from/route_to Zod cap (255) exceeds DB column VarChar(100) — 500 or silent truncation

  • Severity: Medium
  • File: src/schemas/trips.schema.ts:17
  • Failure scenario: route_from/route_to cap at .max(255) (create lines 1718, update 2930) vs trips.route_from/route_to @db.VarChar(100) (schema.prisma:668-669). The route writes String(body.route_from) directly. A 101255 char route passes Zod, then either 500s (P2000 strict mode, no global Prisma mapping) or silently truncates to 100 chars (data loss). The frontend TextFields have no maxLength, so the UI triggers it too.
  • Repro: POST /api/admin/trips with a 150-char route_from → 500 (or truncated 201). Same on PUT.
  • Pinning test: parseBody(CreateTripSchema, { route_from: "x".repeat(101), ... }) should 400 — fails today. Fix: .max(100).

M12. Shared Puppeteer browser disconnecting mid-render fails concurrent PDF requests (no re-acquire/retry)

  • Severity: Medium
  • File: src/utils/html-to-pdf.ts:92
  • Failure scenario: getBrowser() returns the cached module-level browser after a point-in-time connected check; htmlToPdf immediately does b.newPage() with no re-check. Two concurrent renders capture the same handle; if Chromium crashes (OOM/killed) after the guard, both reject. A's finally nulls the shared handle out from under B. Both return 500 even though an immediate retry would relaunch and succeed — there is no re-acquire/retry wrapper.
  • Repro: Fire two simultaneous GET /api/admin/orders-pdf/5 (always renders, no NAS short-circuit), then pkill -9 chrome during the render window → both requests 500; the next request succeeds.
  • Pinning test: Stub the cached browser so connected is true at guard time and false at newPage time; assert htmlToPdf re-acquires and resolves (or both concurrent calls succeed on retry) — fails today.

LOW

L1. Client-supplied TOTP secret up to 255 chars overflows VarChar(255) after encryption, yielding a 500 instead of a 400

  • Severity: Low
  • File: src/schemas/auth.schema.ts:30
  • Failure scenario: TotpEnableSchema.secret caps at .max(255) on the plaintext, but the stored value is the AES-256-GCM ciphertext (base64(IV[12] + ciphertext + tag[16])), which exceeds 255 chars for any plaintext over ~164 chars. users.totp_secret is @db.VarChar(255). The pre-write totp.validate does not bound secret length, and the attacker controls both secret and code, so a long secret reaches the write → P2000 → 500 (strict mode) or silent ciphertext truncation that permanently locks the account out.
  • Repro: Authenticated, 2FA-not-enabled user: generate a 250-char base32 secret + matching TOTP code, POST /api/admin/totp/enable with the correct password → 500 (or corrupted secret).
  • Pinning test: Enable TOTP with a 250-char secret; assert a 400 (not 500) — fails today. Fix: .max(64) (real secrets are 1632 chars).

L2. Offer number sequence is never released on delete when created and finalized in different calendar years

  • Severity: Low
  • File: src/services/offers.service.ts:522
  • Failure scenario: generateOfferNumber keys on new Date().getFullYear() (finalize year), assigning e.g. 2026/NA/001. deleteOffer derives the release year from existing.created_at.getFullYear() (2025) and calls releaseOfferNumber(2025, "2026/NA/001"); releaseSequence reconstructs the highest as 2025/NA/<n>, which never equals 2026/NA/001, so the deletedNumber === highestNumber guard is false and the 2026 counter keeps a permanent gap. invoices.service.ts solves the same problem correctly by parsing the year out of the document number.
  • Repro: Create a draft 2025-12-30, finalize it 2026-01-022026/NA/001. Delete it → next finalized 2026 offer is 2026/NA/002 (gap; no 2026/NA/001).
  • Pinning test: Create a draft in year Y, finalize+delete it in year Y+1; assert the Y+1 sequence counter was released — fails today. Fix: parse the year from the assigned number.

L3. customers list paginates with non-unique sort columns and no id tiebreak → unstable pagination

  • Severity: Low
  • File: src/routes/admin/customers.ts:54
  • Failure scenario: orderBy: { [sortField]: order } over allow-listed fields (name/city/country/company_id, all non-unique) with no { id } tiebreak, used with offset pagination. Rows sharing a sort-key value that straddle a page boundary can reorder between the page-1 and page-2 queries, duplicating one and skipping another. Violates the documented determinism rule; siblings (issued-orders.ts, trips.ts) use compound [..., { id }] orderings. (The same defect exists at received-invoices.ts:144.)
  • Repro: Seed customers with a shared city straddling a limit=2 boundary; page through ?sort=city and observe a customer appearing on two pages while another is skipped.
  • Pinning test: Paginate a sort over duplicate-city customers; assert the concatenated pages contain every customer exactly once — fails today. Fix: orderBy: [{ [sortField]: order }, { id: order }].

L4. Invoice month navigation does not reset the page index → switching to a sparser month shows an empty table

  • Severity: Low
  • File: src/admin/pages/Invoices.tsx:244
  • Failure scenario: prevMonth/nextMonth mutate statsMonth/statsYear but never setPage(1) (unlike the status/search handlers). The list query keys on page + month, and the server never clamps page to total_pages, so paging to page 3 of a busy month then switching to a 1-page month requests ?page=3&month=... and gets an empty data array — empty table for a populated month, with the pager reading "3 of 1".
  • Repro: Open a month with 60+ invoices, go to page 3, click the previous-month chevron to a month with a handful → empty "Zatím nejsou žádné faktury" table. API: GET /api/admin/invoices?page=3&month=<sparse>&year=<y> returns data:[] with non-zero total.
  • Pinning test (component): With page=3, fire nextMonth; assert page resets to 1 — fails today.

L5. Issued-orders sub-list keeps its page index when the parent Orders page changes month → empty list for sparser months

  • Severity: Low
  • File: src/admin/pages/IssuedOrders.tsx:135
  • Failure scenario: IssuedOrders receives month/year as props and holds page in local state with no effect/key resetting it on prop change (the child is not keyed by month/year, so it doesn't remount). Paging to page 2 of a busy month then moving the parent to a sparse month requests page 2 of the new month → empty data → "Zatím žádné vydané objednávky." for a month that has orders on page 1.
  • Repro: Objednávky → Vydané: navigate to a month with ≥26 issued orders, click page 2, then use the parent month chevron to a month with 125 orders → empty sub-list. API equivalent: GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=<y>data:[] with non-zero total.
  • Pinning test (component): With page=2, change the month prop; assert page resets to 1 (or is clamped to total_pages) — fails today.

L6. Settings "System" tab save clobbers freshly-saved "Firma" numbering patterns with stale values

  • Severity: Low
  • File: src/admin/pages/Settings.tsx:297
  • Failure scenario: Both tabs edit the same singleton company_settings row via PUT /company-settings. sysForm carries offer_number_pattern/order_number_pattern/invoice_number_pattern (populated once, gated by a never-reset sysFormInitialized flag). After editing numbering in the Firma tab (which invalidates ["company-settings"]), sysForm stays stale; saving the System tab sends the whole stale form, and the backend applies every !== undefined field, silently reverting the Firma-tab change.
  • Repro: Init the System tab; in the Firma tab change offer_number_pattern and save; return to System and save any unrelated field → offer_number_pattern reverts to its pre-edit value, with a success toast.
  • Pinning test: PUT the offer pattern, then PUT a stale sysForm carrying the old pattern; assert the offer pattern is NOT reverted (or that System-tab saves omit the numbering fields) — fails today.

L7. start_km/end_km accept decimals but DB columns are Int — non-integer odometer reading errors or truncates

  • Severity: Low
  • File: src/schemas/trips.schema.ts:15
  • Failure scenario: start_km/end_km use nonNegativeNumberFromForm (only Number.isFinite && >=0, no integer refine), but the columns are Int. A decimal passes Zod, reaches the Int column → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated into vehicles.actual_km). Same mismatch for vehicles.initial_km/actual_km. The UI's parseInt is used only for the distance widget, not the submitted payload, so the browser is vulnerable too.
  • Repro: POST /api/admin/trips { vehicle_id, trip_date:"2098-01-15", start_km:100.5, end_km:1200.7, route_from:"A", route_to:"B" } → 500 (or truncated values).
  • Pinning test: parseBody(CreateTripSchema, { start_km: 100.5, ... }) should 400 — fails today. Fix: nonNegativeIntFromForm for the four km fields.

L8. AI monthly budget control: severity-mismatched permission, date asymmetry, dead UI button, currency overflow, audit-log boundary (grouped low-severity cluster)

These five independently-confirmed low-severity findings share the "minor blast radius / narrow trigger" profile. Each is real and pinnable.

  • L8a. Company-wide AI monthly budget is mutable by any ai.use holder (not admin/settings). src/routes/admin/ai.ts:58. PUT /api/admin/ai/budget is gated only by requirePermission("ai.use"), an ordinary grantable permission. Setting budget_usd=0 makes assertBudgetAvailable return 402 for everyone (admins included) → company-wide AI DoS; setting 1000000 removes the cost cap. The parallel company-settings writes are gated behind settings.company/settings.system.

    • Repro: As any ai.use holder, PUT /api/admin/ai/budget {"budget_usd":0} → all ai/chat and extract-invoices calls 402.
    • Pinning test: Call PUT /ai/budget as a non-admin ai.use-only user; assert 403 — fails today. Fix: gate behind settings.company/admin.
  • L8b. Audit-log date_from filter boundary shifted ~2h vs date_to (UTC vs local parsing). src/routes/admin/audit-log.ts:46. from = new Date(date_from) parses as UTC midnight (02:00 Prague), while to = new Date(date_to + "T23:59:59") parses as local — so events between 00:00 and 02:00 Prague on the from-date are silently excluded.

    • Repro: GET /api/admin/audit-log?date_from=2026-06-12&date_to=2026-06-12 omits a row logged at 01:30 Prague that day.
    • Pinning test: Insert an audit row at 01:30 local, filter for that day; assert it's returned — fails today.
  • L8c. Draft invoice shows a non-functional "Zobrazit fakturu" PDF button that always errors. src/admin/pages/InvoiceDetail.tsx:1805. The button render guard lacks the status !== "draft" check that OfferDetail/IssuedOrderDetail have; clicking it on a draft opens a blank tab, 404s /file, closes the tab, and toasts an error.

    • Repro: Open a draft invoice, click "Zobrazit fakturu" → blank tab flash + "PDF soubor nenalezen" toast.
    • Pinning test: Render InvoiceDetail for a draft; assert the PDF button is not rendered — fails today. Fix: add && invoice.invoice_number (or non-draft) to the guard.
  • L8d. Odin "Měna" / received-invoice currency over 3 chars 500s the save (VarChar(3) vs schema max(20)). src/components/odin/InvoiceReviewCard.tsx:67 / src/schemas/received-invoices.schema.ts:11. A free-text currency like "Koruna" passes Zod (max(20)) but overflows received_invoices.currency @db.VarChar(3) (P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module's description (max(8000) vs VarChar(500)) and invoice_number (max(255) vs VarChar(100)) are also misaligned.

    • Repro: Save an Odin invoice with currency:"Koruna" → 500 + orphaned NAS file. Equivalent: POST /received-invoices multipart with currency:"Koruna".
    • Pinning test: parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... }) should 400 — fails today. Fix: currency .max(3), description .max(500), invoice_number .max(100).
  • L8e. Warehouse supplier ico/dic Zod caps (255) exceed VarChar(20) columns. src/schemas/warehouse.schema.ts:34. A >20-char ico/dic (bad paste) passes Zod and 500s at Prisma. Low realism (real IČO=8 digits) but a genuine 500-vs-400 defect; the customers schema (company_id/vat_id) shares it.

    • Repro: POST /api/admin/warehouse/suppliers { name:"Test", ico:"123456789012345678901" } → 500.
    • Pinning test: parseBody(CreateSupplierSchema, { ico:"x".repeat(21), ... }) should 400 — fails today. Fix: .max(20).

Adjacent uncited Zod-cap mismatches confirmed during verification (same bug class, fix alongside the above so the gap is closed once): invoice line-item description (max(8000) vs VarChar(500)) and unit (max(255) vs VarChar(20)) — src/schemas/invoices.schema.ts:13,16, High blast radius (whole invoice save rolls back); invoice bank/payment fields payment_method/constant_symbol/bank_swift/bank_iban/bank_account (caps 255 vs VarChar(50)/VarChar(20)) — src/schemas/invoices.schema.ts:30-35, Medium; warehouse /items list ignores the client sort param and sorts by non-unique name with no id tiebreak — src/routes/admin/warehouse.ts:662, Low; year-spanning leave request books all hours against the start year's balance only — src/routes/admin/leave-requests.ts:195, Medium; Dashboard "Přidat jízdu" quick action omits ["vehicles"] invalidation — src/admin/components/dashboard/DashQuickActions.tsx:184, Low. These were verified real but fall outside the 29-item cited set; treat them as a fast-follow batch.


Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative). These corrupt persisted state and worsen on retry; fix before anything cosmetic.

  1. C3 confirmInventorySession returnthrow (one-line change; stops repeatable phantom inventory). Trivial, highest leverage.
  2. C2 confirmIssue cross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existing confirmIssue validation-first pattern; consider a (issue_id, batch_id) unique constraint as a backstop).
  3. C1 leave-balance restore on attendance delete — but do C1 together with H6 and the year-spanning leave finding, since all three live in the leave/attendance balance code and a single corrected helper (per-day, holiday-aware, per-year accumulation, with an inverse on delete) resolves them coherently. Fix the holiday-counting (H6) and per-year split first, then route delete through the same helper.

Phase 1 — Security / privilege & session correctness. 4. H1 session-reuse misclassification (gate the reuse branch on replaced_by_hash alone; reorder vs the expiry check). Self-contained; high user impact. 5. M10 POST /users role-strip for non-admins; L8a AI budget permission gate. Both are permission-boundary one-liners.

Phase 2 — The Zod-cap-vs-DB-column batch (single coordinated sweep). All of H2, M2, M4, M11, L1, L8d, L8e plus the adjacent invoice item/bank caps share one mechanism and one fix shape (.max() alignment + optionally a global P2000→400 mapping in server.ts). Do them in one pass with a shared "cap-must-fit-column" test helper so the class is closed, not whacked one at a time. Highest blast radius within the batch (whole-save rollback): invoice item caps, then H2.

Phase 2 follow-ups (2026-06-12, deliberately deferred): (1) the optional global P2000→400 mapping in the server.ts error handler was NOT added — server.ts exports no app builder, so the mapping would be untestable without first extracting an exported buildApp(); do the refactor + backstop together. (2) Frontend maxLength attributes on the trips (odkud/kam) and invoice bank/payment form fields — pure UX polish now that Zod 400s over-length input with a Czech message; add when next touching those forms.

Phase 3 — FK-existence & error-mapping consistency. M6 (projects update), M8 (warehouse receipts/issues), M5 (CNB graceful degradation). These align the lagging modules with the established offers/orders pre-validation pattern; a shared P2003→Czech-400 mapping (added in Phase 2 if you choose the global-handler route) reduces the per-site work.

Phase 4 — Date/timezone boundaries. M1 (invoice date isoDateString), M7 (warehouse date_to half-open), H5 (Odin Czech-date), L8b (audit-log from). Group because they share the documented date-boundary helpers (isoDateString, utcMidnightOfLocalDay); fixing M1/H5 also removes a day-shift class.

Phase 5 — Pagination determinism & frontend state. H4 (received-invoices pager — add the missing usePaginatedQuery/<Pagination>), L3 (customers + received-invoices id tiebreak), L4/L5 (page-reset on month change), M9 (plan update cap), H3 (issued-order transition flush edits + don't markClean stale state). H4 is the largest UI change; the rest are small, independent guards.

Phase 6 — Cosmetic / low-blast cleanup (parallelizable, no dependencies). M3 (withdrawn; only the stale Chromium-footer comment in html-to-pdf.ts/CLAUDE.md remains as a doc fix), M12 (Puppeteer re-acquire/retry), L2 (offer number release year), L6 (Settings System/Firma clobber), L7 (km integer coercion), L8c (draft PDF button guard), warehouse /items sort param + tiebreak, and the Dashboard ["vehicles"] invalidation.

Rationale for ordering: persisted-data corruption (Phase 0) cannot be allowed to accumulate; security boundaries (Phase 1) are cheap and high-impact; the cap batch (Phase 2) and FK/error mapping (Phase 3) are most efficient done as coordinated sweeps that share a fix shape and tests; date boundaries (Phase 4) share helpers; UI/pagination (Phase 5) depend on nothing earlier but benefit from the cleaner backend; everything in Phase 6 is independent and can be parallelized across contributors.