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>
45 KiB
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.mdconvention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-timetoJSON, local-noon@db.Datewrites, lenient date schemas, net-only documents, grossreceived_invoices.amount, sharedorders.*permission family), and (3) executable repro (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independentrealverdicts. 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:
createLeaveincrementsleave_balances.vacation_used/sick_usedwhen booking leave (lines 1304–1331), butdeleteAttendance(1751–1772) only runsprisma.attendance.delete— it never decrements the balance.getStatus(line 246) andgetBalances(line 530) derivevacation_remaining = vacation_total - vacation_usedfrom 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:
- As an admin (
attendance.manage),POST /api/admin/attendance?action=leavefor user 5,date_from=2026-07-06,date_to=2026-07-10,leave_type=vacation,leave_hours=8→ creates 5 rows and adds 40h tovacation_used. - Read balances:
vacation_used=40,vacation_remaining = total-40. DELETE /api/admin/attendance/:idfor each of the 5 rows.- Read balances again: rows are gone but
vacation_usedis still 40 andvacation_remainingis still down 40h. Only a manual adminhandleBalancesreset fixes it.
- As an admin (
- Pinning test: Book a 5-day vacation, assert
vacation_usedrose by 40h, delete all 5 attendance rows, then assertvacation_usedreturned 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) throwguard. Two issue lines for the same item that resolve to the same batch each pass the independentbatch.quantity >= line.quantitycheck, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes3-5 = -2, flaggingis_consumed=true. The negative/consumed batch drops out ofgetItemTotalStock(is_consumed:falsefilter), so the over-issue is silently hidden from all stock totals. No(issue_id, batch_id)unique constraint exists onsklad_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):- Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16.
POST /issueswith two lines, bothitem_id=X, nobatch_id, eachquantity=5.selectFifoBatchesis 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 bothbatch_id=B1.POST /issues/:id/confirm→ 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to-2withis_consumed=true.- 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 oversession.itemsinterleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upsertssklad_item_locations; a later deficit line makesselectFifoBatchesthrow. 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 resultand 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:
- ItemA exists; ItemB has a location row
quantity=10but fewer unconsumed batches (documented location/batch drift at lines 1244–1250 — also reproducible by consuming batches between session create and confirm). POST /admin/inventory-sessionswith items ordered[{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}](ItemA's line gets the lower id, processed first).POST /admin/inventory-sessions/<id>/confirm→ HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumpeditem_location, and the session is still DRAFT.- Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure.
- ItemA exists; ItemB has a location row
- 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_batchesrows 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-97DELETE/:id,sessions.ts:124-131action=all,profile.ts:98-101password-change) sets ONLYreplaced_at(noreplaced_by_hash, row not deleted). Legitimate rotation (auth.ts:315-318) sets BOTH. The reuse-detection condition atauth.ts:280-282isstoredToken.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 runstx.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:
- Log in from two devices → Session A (laptop), Session B (phone), both
replaced_at=null. - From the laptop,
DELETE /api/admin/sessions/<B.id>→ B getsreplaced_atset only. - After B's access token expires (~15 min), the phone calls
POST /api/admin/refreshwith B's still-present cookie. refreshAccessTokenhits the reuse branch (B'sreplaced_attruthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh.
- Log in from two devices → Session A (laptop), Session B (phone), both
- 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)butorder_items.descriptionis@db.VarChar(500)(schema.prisma:327).createOrder/updateOrdermap the value straight intotx.order_items.createManywith no truncation. A 501–8000 char description passes Zod, overflows the column under MySQL strict mode (P2000), andcreateOrder's catch only re-maps errors carrying astatusproperty — 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 onPUT /api/admin/orders/:idfor an order inprijata/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
sentissued order is both editable AND has valid transitions.handleStatusChange(line 516) sends ONLY{ status: newStatus }; the server'sreplaceItems = editable && Array.isArray(body.items)is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 callsmarkClean({ form, items, sections })with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard soisDirtyflips false and thebeforeunloadwarning never fires. After the transition the order isconfirmed→ read-only, so the lost edits cannot be re-applied. (Contrast:OfferDetail/InvoiceDetaildo NOT callmarkCleanon transition.) - Repro: Open a
sentissued order withorders.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
IssuedOrderDetailfor asentorder, mutate an item, invoke a status transition; assert the PUT payload includes the editeditems(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:
receivedInvoiceListOptionshits/received-invoices, which appliesskip/takewithparsePagination's default limit of 25. The component builds the query with nopage/perPage, reads onlylistQuery.data?.data(the first 25 rows), and renders NO<Pagination>. The "Celkem" footer is sourced from the separate/received-invoices/list-totalsover 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 usesusePaginatedQuery+<Pagination>. - Repro: Seed 26 CZK received invoices in one month.
GET /received-invoices?month=6&year=2026returnsdata.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.saveInvoicesends the raw string. The multipart schema validatesissue_dateonly asz.string().max(255).nullish(), so it passes. The route derivesinvoiceMonth = 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 theUnsignedTinyInt/UnsignedSmallIntcolumns → 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.2026and Save → 500 + orphaned NAS file. Use12.6.2026→ invoice silently filed under month 12. Equivalent:POST /api/admin/received-invoices(multipart) withissue_date:"15.03.2026". - Pinning test:
POST /received-invoiceswithissue_date="15.03.2026"should return a 400 (or normalize) — fails today (500 + NaN write). Fix: useisoDateStringcoercion / 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 90–97) and the approval loop (lines 222–244) count business days with a weekend-only filter (
dow !== 0 && dow !== 6); the file never importsisHoliday. So weekday Czech public holidays inside the range are counted as charged leave days, and approval incrementsvacation_used/sick_usedbytotalBusinessDays * 8including them. The siblingattendance.service.createLeave(line 1270) correctly skipsisHoliday(...), andgetBusinessDaysInMonthexcludes 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. ApproverPUT /.../{id}{status:"approved"}→vacation_usedgrows by 48h instead of 32h; holiday dates are recorded as consumed vacation inattendance. - Pinning test: Approve a vacation request spanning a weekday public holiday; assert
vacation_usedincreased 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/UpdateInvoiceSchematypeissue_date/due_date/tax_date/paid_dateas plainz.string().max(255).nullish()(notisoDateString).createInvoice/updateInvoicedonew Date(String(...))straight into the@db.Datecolumns. A round-tripped API value like"2025-03-15T00:00:00"(thetoJSONoverride emits local time, noZ) parses as local Prague →2025-03-14 22:00 UTC→ Prisma truncates to2025-03-14. The invoice then also lands in the wrong month bucket ingetInvoiceStats/buildInvoiceWhere. Every other@db.Date-writing module usesisoDateString(which slices the time off); invoices is the lone outlier. - Repro:
POST /api/admin/invoiceswithissue_date:"2025-03-01T00:00:00"→ DB stores2025-02-28;GET .../stats?month=3&year=2025excludes it and inflates February. - Pinning test: Create an invoice with
issue_date="2025-03-01T00:00:00"; assert the stored date is2025-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)vsorder_items.unit @db.VarChar(20)(schema.prisma:330). A 21–255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-mapsstatus-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 enforcesmaxLength:20client-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).
M3. Offer PDF page footer ("Strana X z Y") never renders — WITHDRAWN 2026-06-12 (false positive)
- 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-74comment 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@pagemargin 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.tsand CLAUDE.md so future work doesn't repeat this; thefooterTemplatemechanism 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)vsorders.customer_order_number @db.VarChar(100)(schema.prisma:356). A 101–255 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 handlesstatus-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 onPUT /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.0is unguarded. When CNB is unreachable and no cached entry exists for the issue date,getRatethrows "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-invoicesstats,ai-tools) wraptoCzkand 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; thesave=1archival is silently swallowed client-side, leaving no NAS copy. - Pinning test: Mock
getRateto 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:
updateProjectwritescustomer_id/responsible_user_id/quotation_id/order_idstraight intoprisma.projects.updatewith only int coercion and nofindUnique. The FKs areonDelete: Restrict, so a non-existent id raises P2003 → generic 500.createProjectvalidatescustomer_id/responsible_user_idand 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/:idwith a non-existentcustomer_id; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirrorcreateProject'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_atis@db.DateTime(0)storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is> 00:00Zand 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-12returns 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-openlt 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_idraises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) andtrips.tsall 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 /issueswith a valid item + nonexistent explicitbatch_id→ 500. - Pinning test:
POST /receiptswith a nonexistentitem_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/createOverrideall guard the per-cell cap of 3 viaassertEntryCapAvailable, butupdateEntrydoes not. Expanding an entry'sdate_from/date_toonto a day that already holds 3 active entries succeeds, producing 4.resolveCell/resolveGridcap 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
PATCHanother 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_idstraight tocreateUser;createUseronly rejects the literaladminrole. The PUT handler explicitly stripsrole_id/is_activefor non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: ausers.createholder can mint an account on any high-privilege non-admin role (e.g. one bundlingsettings.rolesorusers.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 bundlingsettings.roles/users.edit→ 201; the new account holds permissions M never had. - Pinning test: As a non-admin
users.createcaller, POST a user with a privilegedrole_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_tocap at.max(255)(create lines 17–18, update 29–30) vstrips.route_from/route_to @db.VarChar(100)(schema.prisma:668-669). The route writesString(body.route_from)directly. A 101–255 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 nomaxLength, so the UI triggers it too. - Repro:
POST /api/admin/tripswith a 150-charroute_from→ 500 (or truncated 201). Same onPUT. - 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-timeconnectedcheck;htmlToPdfimmediately doesb.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), thenpkill -9 chromeduring the render window → both requests 500; the next request succeeds. - Pinning test: Stub the cached browser so
connectedis true at guard time and false atnewPagetime; asserthtmlToPdfre-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.secretcaps 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_secretis@db.VarChar(255). The pre-writetotp.validatedoes not bound secret length, and the attacker controls bothsecretandcode, 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/enablewith 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 16–32 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:
generateOfferNumberkeys onnew Date().getFullYear()(finalize year), assigning e.g.2026/NA/001.deleteOfferderives the release year fromexisting.created_at.getFullYear()(2025) and callsreleaseOfferNumber(2025, "2026/NA/001");releaseSequencereconstructs the highest as2025/NA/<n>, which never equals2026/NA/001, so thedeletedNumber === highestNumberguard is false and the 2026 counter keeps a permanent gap.invoices.service.tssolves the same problem correctly by parsing the year out of the document number. - Repro: Create a draft
2025-12-30, finalize it2026-01-02→2026/NA/001. Delete it → next finalized 2026 offer is2026/NA/002(gap; no2026/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 atreceived-invoices.ts:144.) - Repro: Seed customers with a shared
citystraddling alimit=2boundary; page through?sort=cityand observe a customer appearing on two pages while another is skipped. - Pinning test: Paginate a sort over duplicate-
citycustomers; 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/nextMonthmutatestatsMonth/statsYearbut neversetPage(1)(unlike the status/search handlers). The list query keys onpage+ month, and the server never clamps page tototal_pages, so paging to page 3 of a busy month then switching to a 1-page month requests?page=3&month=...and gets an emptydataarray — 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>returnsdata:[]with non-zerototal. - Pinning test (component): With
page=3, firenextMonth; assertpageresets 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:
IssuedOrdersreceivesmonth/yearas props and holdspagein 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 → emptydata→ "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 1–25 orders → empty sub-list. API equivalent:
GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=<y>→data:[]with non-zerototal. - Pinning test (component): With
page=2, change themonthprop; assertpageresets to 1 (or is clamped tototal_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_settingsrow viaPUT /company-settings.sysFormcarriesoffer_number_pattern/order_number_pattern/invoice_number_pattern(populated once, gated by a never-resetsysFormInitializedflag). After editing numbering in the Firma tab (which invalidates["company-settings"]),sysFormstays stale; saving the System tab sends the whole stale form, and the backend applies every!== undefinedfield, silently reverting the Firma-tab change. - Repro: Init the System tab; in the Firma tab change
offer_number_patternand save; return to System and save any unrelated field →offer_number_patternreverts 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_kmusenonNegativeNumberFromForm(onlyNumber.isFinite && >=0, no integer refine), but the columns areInt. A decimal passes Zod, reaches theIntcolumn → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated intovehicles.actual_km). Same mismatch forvehicles.initial_km/actual_km. The UI'sparseIntis 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:nonNegativeIntFromFormfor 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.useholder (not admin/settings).src/routes/admin/ai.ts:58.PUT /api/admin/ai/budgetis gated only byrequirePermission("ai.use"), an ordinary grantable permission. Settingbudget_usd=0makesassertBudgetAvailablereturn 402 for everyone (admins included) → company-wide AI DoS; setting1000000removes the cost cap. The parallelcompany-settingswrites are gated behindsettings.company/settings.system.- Repro: As any
ai.useholder,PUT /api/admin/ai/budget {"budget_usd":0}→ allai/chatandextract-invoicescalls 402. - Pinning test: Call
PUT /ai/budgetas a non-adminai.use-only user; assert 403 — fails today. Fix: gate behindsettings.company/admin.
- Repro: As any
-
L8b. Audit-log
date_fromfilter boundary shifted ~2h vsdate_to(UTC vs local parsing).src/routes/admin/audit-log.ts:46.from = new Date(date_from)parses as UTC midnight (02:00 Prague), whileto = 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-12omits 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.
- Repro:
-
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 thestatus !== "draft"check thatOfferDetail/IssuedOrderDetailhave; 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
InvoiceDetailfor 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 schemamax(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 overflowsreceived_invoices.currency @db.VarChar(3)(P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module'sdescription(max(8000)vsVarChar(500)) andinvoice_number(max(255)vsVarChar(100)) are also misaligned.- Repro: Save an Odin invoice with
currency:"Koruna"→ 500 + orphaned NAS file. Equivalent:POST /received-invoicesmultipart withcurrency:"Koruna". - Pinning test:
parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... })should 400 — fails today. Fix:currency .max(3),description .max(500),invoice_number .max(100).
- Repro: Save an Odin invoice with
-
L8e. Warehouse supplier
ico/dicZod caps (255) exceedVarChar(20)columns.src/schemas/warehouse.schema.ts:34. A >20-charico/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).
- Repro:
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)vsVarChar(500)) andunit(max(255)vsVarChar(20)) —src/schemas/invoices.schema.ts:13,16, High blast radius (whole invoice save rolls back); invoice bank/payment fieldspayment_method/constant_symbol/bank_swift/bank_iban/bank_account(caps 255 vsVarChar(50)/VarChar(20)) —src/schemas/invoices.schema.ts:30-35, Medium; warehouse/itemslist ignores the clientsortparam and sorts by non-uniquenamewith noidtiebreak —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 verifiedrealbut fall outside the 29-item cited set; treat them as a fast-follow batch.
Recommended Fix Order (dependencies considered)
Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative). These corrupt persisted state and worsen on retry; fix before anything cosmetic.
- C3
confirmInventorySessionreturn→throw(one-line change; stops repeatable phantom inventory). Trivial, highest leverage. - C2
confirmIssuecross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existingconfirmIssuevalidation-first pattern; consider a(issue_id, batch_id)unique constraint as a backstop). - 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.tserror handler was NOT added —server.tsexports no app builder, so the mapping would be untestable without first extracting an exportedbuildApp(); do the refactor + backstop together. (2) FrontendmaxLengthattributes 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.