Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b51ea2505e | ||
|
|
fd2e613aa5 | ||
|
|
1f2aff8325 | ||
|
|
e11765bf0e | ||
|
|
8f74efba97 | ||
|
|
b9103c59f0 | ||
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 | ||
|
|
164ce54adf | ||
|
|
6877919133 |
334
AUDIT-2026-06-12.md
Normal file
334
AUDIT-2026-06-12.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# 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 1304–1331), but `deleteAttendance` (1751–1772) 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 1244–1250 — 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 501–8000 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 90–97) and the approval loop (lines 222–244) 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 21–255 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)`.
|
||||
|
||||
### ~~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-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 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 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 17–18, update 29–30) vs `trips.route_from/route_to @db.VarChar(100)` (`schema.prisma:668-669`). The route writes `String(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 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 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:** `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-02` → `2026/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 1–25 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
1. **C3** `confirmInventorySession` `return`→`throw` (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.
|
||||
@@ -204,8 +204,11 @@ descriptions for unnumbered drafts. Audit failures are non-fatal.
|
||||
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||
suffixes. The issued-order PDF gets language from the document's `language`
|
||||
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
|
||||
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
|
||||
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating-
|
||||
footer mechanisms coexist and BOTH work: Puppeteer `footerTemplate`
|
||||
(invoices + issued orders — `htmlToPdf(html, { footerTemplate })`) and CSS
|
||||
`@page` margin boxes (`@bottom-center` + `counter(page)`, offer PDF —
|
||||
supported since Chrome 131, Nov 2024). Don't migrate one to the other.
|
||||
- **Shared modules — extend, never fork local copies:**
|
||||
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||
|
||||
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Sidebar icon refresh — design
|
||||
|
||||
**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens)
|
||||
|
||||
## Problem
|
||||
|
||||
All 31 sidebar items have icons, but 13 share a glyph with another item
|
||||
(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury
|
||||
uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat
|
||||
the purpose of icons as unique visual anchors.
|
||||
|
||||
## Decisions (user-approved)
|
||||
|
||||
1. **Full refresh** of all items (option B over targeted fix).
|
||||
2. **Tile language (option B of 3 rendered directions):** every item's glyph
|
||||
sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a
|
||||
**soft color wash — one muted hue per menu section**:
|
||||
Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` ·
|
||||
Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`.
|
||||
3. **Odin stays unique:** the only **solid** (gradient) tile and the only
|
||||
animated icon. No animation anywhere else. Red stays reserved for
|
||||
Odin/primary.
|
||||
4. **Glyph set** approved on the full-set screen: receipt for Faktury,
|
||||
business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování,
|
||||
ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for
|
||||
Inventura, shelf rack for Lokace, arrows into/out of a box for
|
||||
Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for
|
||||
Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti,
|
||||
timesheet for Moje historie (docházka), shapes for Kategorie, chart frame
|
||||
for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique
|
||||
in the set): clock (Záznam docházky), calendar (Plán prací), people
|
||||
(Uživatelé), gear (Nastavení), box (Položky).
|
||||
|
||||
## Implementation
|
||||
|
||||
- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark
|
||||
mains, full main/light/dark/contrastText so cssVariables emits `*Channel`
|
||||
tokens) + TS module augmentation.
|
||||
- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs
|
||||
(stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for
|
||||
Odin (its icon ships its own tile).
|
||||
- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor:
|
||||
rgba(<hue>.mainChannel / 0.12)`, glyph color `<hue>.main`, svg 16 px)
|
||||
around every non-`rawIcon` item. No hardcoded hex; washes are
|
||||
channel-alpha so both schemes work.
|
||||
- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique
|
||||
static markup (no two items may ever share a glyph again).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Animation for non-Odin icons (explicitly rejected), page-level icons,
|
||||
mobile drawer changes (it reuses SidebarNav).
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.27",
|
||||
"version": "2.4.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.27",
|
||||
"version": "2.4.33",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.27",
|
||||
"version": "2.4.33",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0);
|
||||
|
||||
|
||||
-- Stock invariants enforced by the database itself: a future cumulative-
|
||||
-- decrement bug (the C2 class — duplicate issue lines driving a batch
|
||||
-- negative and silently vanishing from stock totals) becomes a loud error
|
||||
-- instead of hidden corruption. All databases verified clean of negative
|
||||
-- rows before this migration (2026-06-12).
|
||||
ALTER TABLE `sklad_batches`
|
||||
ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||
|
||||
ALTER TABLE `sklad_item_locations`
|
||||
ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||
@@ -65,7 +65,9 @@ model audit_logs {
|
||||
new_values String? @db.LongText
|
||||
user_agent String? @db.Text
|
||||
session_id String? @db.VarChar(128)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
||||
// DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
|
||||
// the session timezone (2026-06-12 hardening migration).
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
|
||||
@@index([action], map: "idx_audit_logs_action")
|
||||
@@index([created_at], map: "idx_audit_logs_created")
|
||||
@@ -457,6 +459,7 @@ model project_notes {
|
||||
user_name String? @db.VarChar(100)
|
||||
content String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
edited_at DateTime? @db.DateTime(0)
|
||||
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
|
||||
|
||||
@@index([project_id], map: "project_id")
|
||||
|
||||
137
src/__tests__/ai-budget-permission.test.ts
Normal file
137
src/__tests__/ai-budget-permission.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import aiRoutes from "../routes/admin/ai";
|
||||
import { getBudgetUsd, setBudgetUsd } from "../services/ai.service";
|
||||
|
||||
/**
|
||||
* Pinning test for audit finding L8a: the company-wide AI monthly budget must
|
||||
* NOT be mutable by an ordinary ai.use holder (budget_usd=0 is a company-wide
|
||||
* AI DoS; a huge value removes the cost cap). It is a company setting —
|
||||
* settings.company / settings.system / admin only.
|
||||
*/
|
||||
|
||||
const N = "ai_budget_perm_";
|
||||
const stamp = Date.now().toString(36);
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let aiUserToken: string;
|
||||
let aiRoleId: number;
|
||||
let savedBudget: number;
|
||||
|
||||
function token(user: { id: number; username: string; roleName: string }) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
const roles = await prisma.roles.findMany({
|
||||
where: { name: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
const roleIds = roles.map((r) => r.id);
|
||||
if (roleIds.length > 0) {
|
||||
await prisma.role_permissions.deleteMany({
|
||||
where: { role_id: { in: roleIds } },
|
||||
});
|
||||
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
savedBudget = await getBudgetUsd();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = token({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: "admin",
|
||||
});
|
||||
|
||||
// Ordinary ai.use holder — may chat, must NOT control the budget.
|
||||
const role = await prisma.roles.create({
|
||||
data: { name: `${N}aiuse_${stamp}`, display_name: "AI Use Only" },
|
||||
});
|
||||
aiRoleId = role.id;
|
||||
const perm = await prisma.permissions.findFirst({
|
||||
where: { name: "ai.use" },
|
||||
});
|
||||
if (!perm) throw new Error("Test setup: ai.use permission missing");
|
||||
await prisma.role_permissions.create({
|
||||
data: { role_id: aiRoleId, permission_id: perm.id },
|
||||
});
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user_${stamp}`,
|
||||
email: `${N}${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
first_name: "AiUse",
|
||||
last_name: "Only",
|
||||
is_active: true,
|
||||
role_id: aiRoleId,
|
||||
},
|
||||
});
|
||||
aiUserToken = token({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleName: role.name,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await setBudgetUsd(savedBudget); // restore whatever the test DB had
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("PUT /ai/budget permission gate (audit L8a)", () => {
|
||||
it("rejects an ordinary ai.use holder with 403", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/admin/ai/budget",
|
||||
headers: {
|
||||
Authorization: `Bearer ${aiUserToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { budget_usd: 0 },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
// ...and the budget must be untouched.
|
||||
expect(await getBudgetUsd()).toBe(savedBudget);
|
||||
});
|
||||
|
||||
it("allows an admin to set the budget", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/admin/ai/budget",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { budget_usd: savedBudget },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -765,6 +765,34 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("get_document_totals: received_invoices sums gross + converts to CZK", async () => {
|
||||
// invoices.view alone unlocks the received-invoices branch.
|
||||
const res = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "received_invoices", month: 6, year: 2098 },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
period: string;
|
||||
invoice_count: number;
|
||||
totals: { currency: string; amount: number }[];
|
||||
total_czk: number;
|
||||
};
|
||||
expect(r.period).toBe("6/2098");
|
||||
expect(r.invoice_count).toBe(1);
|
||||
expect(r.totals).toEqual([{ currency: "CZK", amount: 1210 }]);
|
||||
expect(r.total_czk).toBe(1210); // CZK rows convert 1:1, no rate fetch
|
||||
|
||||
// orders.view alone cannot read expense totals.
|
||||
const denied = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "received_invoices" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||
const res = await executeTool(
|
||||
"get_top_customers",
|
||||
|
||||
139
src/__tests__/attendance-delete-balance.test.ts
Normal file
139
src/__tests__/attendance-delete-balance.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createLeave, deleteAttendance } from "../services/attendance.service";
|
||||
import { localDateStr } from "../utils/date";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding C1: deleting a vacation/sick attendance
|
||||
* record must restore the hours it charged to leave_balances. Without the
|
||||
* restore, cancelling booked leave by deleting its rows permanently inflates
|
||||
* vacation_used/sick_used.
|
||||
*/
|
||||
|
||||
const N = "att_delbal_";
|
||||
|
||||
let userId: number;
|
||||
|
||||
/** First Monday of March in the given year — always before the earliest
|
||||
* possible Easter holiday and clear of all fixed Czech holidays, so a
|
||||
* Mon-Fri range books exactly 5 days. (Far-future year per fixture hygiene.) */
|
||||
function firstMondayOfMarch(year: number): Date {
|
||||
const d = new Date(year, 2, 1, 12, 0, 0);
|
||||
while (d.getDay() !== 1) d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const users = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = users.map((u) => u.id);
|
||||
if (ids.length > 0) {
|
||||
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Admin role not found — seed the database first");
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Delete",
|
||||
last_name: "Balance",
|
||||
is_active: true,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function balance(year: number) {
|
||||
return prisma.leave_balances.findFirst({ where: { user_id: userId, year } });
|
||||
}
|
||||
|
||||
describe("deleteAttendance leave-balance restore (audit C1)", () => {
|
||||
it("restores vacation_used when booked vacation rows are deleted", async () => {
|
||||
const monday = firstMondayOfMarch(2098);
|
||||
const friday = new Date(monday);
|
||||
friday.setDate(friday.getDate() + 4);
|
||||
|
||||
const created = await createLeave(
|
||||
{
|
||||
user_id: userId,
|
||||
date_from: localDateStr(monday),
|
||||
date_to: localDateStr(friday),
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
expect("created" in created && created.created).toBe(5);
|
||||
expect(Number((await balance(2098))?.vacation_used)).toBe(40);
|
||||
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { user_id: userId, leave_type: "vacation" },
|
||||
});
|
||||
expect(rows).toHaveLength(5);
|
||||
for (const row of rows) {
|
||||
const res = await deleteAttendance(row.id);
|
||||
expect("success" in res && res.success).toBe(true);
|
||||
}
|
||||
|
||||
expect(Number((await balance(2098))?.vacation_used)).toBe(0);
|
||||
});
|
||||
|
||||
it("restores sick_used when a booked sick day is deleted", async () => {
|
||||
const monday = firstMondayOfMarch(2099);
|
||||
await createLeave(
|
||||
{
|
||||
user_id: userId,
|
||||
date_from: localDateStr(monday),
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
expect(Number((await balance(2099))?.sick_used)).toBe(8);
|
||||
|
||||
const row = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId, leave_type: "sick" },
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
await deleteAttendance(row!.id);
|
||||
|
||||
expect(Number((await balance(2099))?.sick_used)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not touch balances when deleting a plain work shift", async () => {
|
||||
const wednesday = firstMondayOfMarch(2098);
|
||||
wednesday.setDate(wednesday.getDate() + 9); // a weekday a week later
|
||||
const shift = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: wednesday,
|
||||
leave_type: "work",
|
||||
},
|
||||
});
|
||||
|
||||
const before = await balance(2098);
|
||||
await deleteAttendance(shift.id);
|
||||
const after = await balance(2098);
|
||||
|
||||
expect(Number(after?.vacation_used)).toBe(Number(before?.vacation_used));
|
||||
expect(Number(after?.sick_used)).toBe(Number(before?.sick_used));
|
||||
});
|
||||
});
|
||||
96
src/__tests__/audit-log-date-filter.test.ts
Normal file
96
src/__tests__/audit-log-date-filter.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import auditLogRoutes from "../routes/admin/audit-log";
|
||||
|
||||
/**
|
||||
* Pinning test for audit finding L8b: the audit-log date_from boundary must
|
||||
* be the LOCAL midnight of the requested day, symmetric with the date_to
|
||||
* side (which already parses "T23:59:59" as local). The old
|
||||
* `new Date(date_from)` was UTC midnight = 01:00/02:00 Prague, silently
|
||||
* excluding events logged in the first morning hours of the from-day.
|
||||
*
|
||||
* NOTE: audit_logs.created_at is @db.Timestamp(0) — capped at 2038, so the
|
||||
* far-future fixture year is 2037, not 2098.
|
||||
*/
|
||||
|
||||
const N = "auditlog_datefilter_";
|
||||
const DAY = "2037-04-09";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let earlyId: number; // 00:30 local — inside the old excluded window
|
||||
let middayId: number; // 12:00 local
|
||||
let prevDayId: number; // 23:30 local the day BEFORE — must stay excluded
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.audit_logs.deleteMany({
|
||||
where: { description: { contains: N } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(auditLogRoutes, { prefix: "/api/admin/audit-log" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
const early = await prisma.audit_logs.create({
|
||||
data: {
|
||||
action: "test",
|
||||
description: `${N}early`,
|
||||
created_at: new Date(2037, 3, 9, 0, 30, 0),
|
||||
},
|
||||
});
|
||||
earlyId = early.id;
|
||||
const midday = await prisma.audit_logs.create({
|
||||
data: {
|
||||
action: "test",
|
||||
description: `${N}midday`,
|
||||
created_at: new Date(2037, 3, 9, 12, 0, 0),
|
||||
},
|
||||
});
|
||||
middayId = midday.id;
|
||||
const prevDay = await prisma.audit_logs.create({
|
||||
data: {
|
||||
action: "test",
|
||||
description: `${N}prevday`,
|
||||
created_at: new Date(2037, 3, 8, 23, 30, 0),
|
||||
},
|
||||
});
|
||||
prevDayId = prevDay.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("audit-log date_from boundary (audit L8b)", () => {
|
||||
it("includes events from the first morning hours of the from-day", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/audit-log?date_from=${DAY}&date_to=${DAY}&limit=100`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||
|
||||
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
|
||||
expect(ids).toContain(middayId);
|
||||
expect(ids).not.toContain(prevDayId); // previous evening stays out
|
||||
});
|
||||
});
|
||||
136
src/__tests__/db-constraints.test.ts
Normal file
136
src/__tests__/db-constraints.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
|
||||
/**
|
||||
* Pins the 2026-06-12 DB hardening migration:
|
||||
* 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB
|
||||
* itself rejects negative stock, so a future C2-class bug (cumulative
|
||||
* decrements driving a batch negative) becomes a loud error instead of
|
||||
* silently hidden corruption.
|
||||
* 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap.
|
||||
*/
|
||||
|
||||
const N = "db_constraints_";
|
||||
|
||||
let itemId: number;
|
||||
let locationId: number;
|
||||
let receiptId: number;
|
||||
let lineAId: number;
|
||||
let lineBId: number;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.sklad_batches.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_item_locations.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipt_lines.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||
await prisma.sklad_locations.deleteMany({
|
||||
where: { name: { contains: N } },
|
||||
});
|
||||
await prisma.audit_logs.deleteMany({
|
||||
where: { description: { contains: N } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const item = await prisma.sklad_items.create({
|
||||
data: { name: `${N}item`, unit: "ks" },
|
||||
});
|
||||
itemId = item.id;
|
||||
const location = await prisma.sklad_locations.create({
|
||||
data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` },
|
||||
});
|
||||
locationId = location.id;
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}receipt`, status: "CONFIRMED" },
|
||||
});
|
||||
receiptId = receipt.id;
|
||||
const lineA = await prisma.sklad_receipt_lines.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
item_id: itemId,
|
||||
quantity: 5,
|
||||
unit_price: 1,
|
||||
},
|
||||
});
|
||||
lineAId = lineA.id;
|
||||
const lineB = await prisma.sklad_receipt_lines.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
item_id: itemId,
|
||||
quantity: 5,
|
||||
unit_price: 1,
|
||||
},
|
||||
});
|
||||
lineBId = lineB.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("stock CHECK constraints", () => {
|
||||
it("rejects inserting a negative batch quantity", async () => {
|
||||
await expect(
|
||||
prisma.sklad_batches.create({
|
||||
data: {
|
||||
item_id: itemId,
|
||||
receipt_line_id: lineAId,
|
||||
quantity: -1,
|
||||
original_qty: 5,
|
||||
unit_price: 1,
|
||||
is_consumed: false,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects updating a batch quantity below zero", async () => {
|
||||
const batch = await prisma.sklad_batches.create({
|
||||
data: {
|
||||
item_id: itemId,
|
||||
receipt_line_id: lineBId,
|
||||
quantity: 5,
|
||||
original_qty: 5,
|
||||
unit_price: 1,
|
||||
is_consumed: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`,
|
||||
).rejects.toThrow();
|
||||
const fresh = await prisma.sklad_batches.findUnique({
|
||||
where: { id: batch.id },
|
||||
});
|
||||
expect(Number(fresh?.quantity)).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects a negative item_location quantity", async () => {
|
||||
await expect(
|
||||
prisma.sklad_item_locations.create({
|
||||
data: { item_id: itemId, location_id: locationId, quantity: -1 },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit_logs.created_at has no 2038 cap", () => {
|
||||
it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => {
|
||||
const row = await prisma.audit_logs.create({
|
||||
data: {
|
||||
action: "test",
|
||||
description: `${N}future`,
|
||||
created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)),
|
||||
},
|
||||
});
|
||||
expect(row.created_at?.getUTCFullYear()).toBe(2098);
|
||||
});
|
||||
});
|
||||
73
src/__tests__/html-to-pdf-retry.test.ts
Normal file
73
src/__tests__/html-to-pdf-retry.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M12: a shared Puppeteer browser that
|
||||
* disconnects between getBrowser()'s point-in-time `connected` check and the
|
||||
* actual render must be re-acquired once — instead of failing the request
|
||||
* with a 500 that an immediate retry would have served fine.
|
||||
*/
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() }));
|
||||
|
||||
vi.mock("puppeteer", () => ({ default: { launch: launchMock } }));
|
||||
|
||||
function healthyBrowser(pdfBytes = new Uint8Array([1, 2, 3])) {
|
||||
return {
|
||||
connected: true,
|
||||
newPage: vi.fn(async () => ({
|
||||
setContent: vi.fn(async () => undefined),
|
||||
pdf: vi.fn(async () => pdfBytes),
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Browser whose connection dies the moment a page is requested — the
|
||||
* post-guard crash window from the finding. */
|
||||
function dyingBrowser() {
|
||||
const b = {
|
||||
connected: true,
|
||||
newPage: vi.fn(async () => {
|
||||
b.connected = false;
|
||||
throw new Error("Protocol error (Target.createTarget): Session closed.");
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
launchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("htmlToPdf browser re-acquire (audit M12)", () => {
|
||||
it("relaunches once and resolves when the cached browser died mid-acquire", async () => {
|
||||
launchMock
|
||||
.mockResolvedValueOnce(dyingBrowser())
|
||||
.mockResolvedValueOnce(healthyBrowser());
|
||||
|
||||
const { htmlToPdf } = await import("../utils/html-to-pdf");
|
||||
const pdf = await htmlToPdf("<html><body>retry</body></html>");
|
||||
|
||||
expect(Buffer.isBuffer(pdf)).toBe(true);
|
||||
expect(launchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does NOT retry a genuine render error on a healthy browser", async () => {
|
||||
const browser = healthyBrowser();
|
||||
browser.newPage = vi.fn(async () => ({
|
||||
setContent: vi.fn(async () => {
|
||||
throw new Error("Render timeout");
|
||||
}),
|
||||
pdf: vi.fn(async () => new Uint8Array()),
|
||||
close: vi.fn(async () => undefined),
|
||||
}));
|
||||
launchMock.mockResolvedValue(browser);
|
||||
|
||||
const { htmlToPdf } = await import("../utils/html-to-pdf");
|
||||
await expect(htmlToPdf("<html></html>")).rejects.toThrow("Render timeout");
|
||||
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
91
src/__tests__/invoice-date-coercion.test.ts
Normal file
91
src/__tests__/invoice-date-coercion.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import invoicesRoutes from "../routes/admin/invoices";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../schemas/invoices.schema";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M1: invoice date fields must go through the
|
||||
* lenient ISO-date coercion. A round-tripped datetime string like
|
||||
* "2026-03-02T00:00:00" (the toJSON override emits LOCAL time, no Z) parses
|
||||
* as local Prague midnight = 23:00Z of the PREVIOUS day, and Prisma truncates
|
||||
* @db.Date columns to the UTC date part — the invoice lands a day early and
|
||||
* in the wrong month bucket.
|
||||
*/
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("invoice @db.Date coercion (audit M1)", () => {
|
||||
it("stores the submitted calendar day when issue_date carries a time part", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/invoices",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
status: "draft",
|
||||
issue_date: "2026-03-02T00:00:00",
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const id = res.json().data.id as number;
|
||||
createdInvoiceIds.push(id);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id } });
|
||||
// @db.Date reads back as UTC midnight of the stored calendar day.
|
||||
expect(row?.issue_date?.toISOString().slice(0, 10)).toBe("2026-03-02");
|
||||
});
|
||||
|
||||
it("schema rejects a Czech-format date", () => {
|
||||
const result = CreateInvoiceSchema.safeParse({
|
||||
issue_date: "15.03.2026",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("schema normalizes an empty string to null (edit forms clear with '')", () => {
|
||||
const result = CreateInvoiceSchema.safeParse({ issue_date: "" });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.issue_date).toBeNull();
|
||||
});
|
||||
|
||||
it("update schema strips the time part from tax_date", () => {
|
||||
const result = UpdateInvoiceSchema.safeParse({
|
||||
tax_date: "2026-03-02T00:00:00",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.tax_date).toBe("2026-03-02");
|
||||
});
|
||||
});
|
||||
108
src/__tests__/invoice-pdf-cnb-degrade.test.ts
Normal file
108
src/__tests__/invoice-pdf-cnb-degrade.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
import { getRate } from "../services/exchange-rates";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M5: a foreign-currency invoice PDF must
|
||||
* degrade gracefully when the CNB rate lookup fails — render WITHOUT the CZK
|
||||
* VAT recap/conversion footnote instead of 500ing the whole preview AND the
|
||||
* NAS archival. The rate only feeds the recap conversion; the document itself
|
||||
* is renderable without it.
|
||||
*/
|
||||
|
||||
vi.mock("../services/exchange-rates", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../services/exchange-rates")>();
|
||||
return { ...actual, getRate: vi.fn() };
|
||||
});
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function makeEurInvoice() {
|
||||
// Draft → no number consumed; EUR → triggers the CNB rate lookup.
|
||||
const invoice = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "EUR",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
items: [
|
||||
{
|
||||
description: "CNB-DEGRADE-MARKER",
|
||||
quantity: 1,
|
||||
unit_price: 100,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
createdInvoiceIds.push(invoice.id);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
describe("invoice PDF vs unreachable CNB (audit M5)", () => {
|
||||
it("renders the invoice without the CZK recap when the rate lookup throws", async () => {
|
||||
vi.mocked(getRate).mockRejectedValue(
|
||||
new Error("Nepodařilo se získat aktuální kurzy z ČNB"),
|
||||
);
|
||||
const invoice = await makeEurInvoice();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("CNB-DEGRADE-MARKER");
|
||||
// The CZK recap and the conversion footnote are omitted — their numbers
|
||||
// cannot be computed without the rate.
|
||||
expect(html).not.toContain("Rekapitulace DPH v Kč");
|
||||
expect(html).not.toContain("Přepočet kurzem ČNB");
|
||||
});
|
||||
|
||||
it("still renders the recap + conversion footnote when the rate is available", async () => {
|
||||
vi.mocked(getRate).mockResolvedValue(25.0);
|
||||
const invoice = await makeEurInvoice();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("Rekapitulace DPH v Kč");
|
||||
expect(html).toContain("Přepočet kurzem ČNB");
|
||||
expect(html).toContain("25,000");
|
||||
});
|
||||
});
|
||||
@@ -539,6 +539,40 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => {
|
||||
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||
// full payload + status in ONE PUT while the order is still editable
|
||||
// (sent). The server must apply the items AND the transition together.
|
||||
const order = await mkIssued({});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/issued-orders/${order.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
payload: {
|
||||
status: "confirmed",
|
||||
items: [
|
||||
{
|
||||
description: "Flushnutá položka",
|
||||
quantity: 2,
|
||||
unit_price: 50,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { issued_order_items: true },
|
||||
});
|
||||
expect(row!.status).toBe("confirmed");
|
||||
expect(row!.issued_order_items.map((i) => i.description)).toContain(
|
||||
"Flushnutá položka",
|
||||
);
|
||||
});
|
||||
|
||||
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
||||
const order = await mkIssued({});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
|
||||
202
src/__tests__/leave-requests-holidays.test.ts
Normal file
202
src/__tests__/leave-requests-holidays.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import leaveRequestsRoutes from "../routes/admin/leave-requests";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding H6 + the year-spanning fast-follow:
|
||||
* leave-request creation and approval must mirror attendance.service
|
||||
* createLeave — skip Czech public holidays (a weekday holiday is already
|
||||
* paid/free; charging it double-deducts) and book each day's hours against
|
||||
* ITS OWN calendar year's balance (a Dec→Jan request used to charge
|
||||
* everything to the start year).
|
||||
*
|
||||
* Fixture math (verified): 2098-05-01 (Thu) and 2098-05-08 (Thu) are both
|
||||
* weekday holidays → the 05-01..05-08 range has 4 chargeable days (32h),
|
||||
* not 6 (48h). 2098-12-28 (Sun)..2099-01-05 (Mon) has 3 chargeable days in
|
||||
* 2098 (29/30/31 = 24h) and 2 in 2099 (Jan 2 + Jan 5 = 16h; Jan 1 is a
|
||||
* holiday).
|
||||
*/
|
||||
|
||||
const N = "leave_holiday_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let userToken: string;
|
||||
let userId: number;
|
||||
|
||||
async function cleanup() {
|
||||
const users = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = users.map((u) => u.id);
|
||||
if (ids.length > 0) {
|
||||
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.leave_requests.deleteMany({
|
||||
where: { user_id: { in: ids } },
|
||||
});
|
||||
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(leaveRequestsRoutes, {
|
||||
prefix: "/api/admin/leave-requests",
|
||||
});
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
const role = await prisma.roles.findFirst();
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
first_name: "Leave",
|
||||
last_name: "Holiday",
|
||||
is_active: true,
|
||||
role_id: role!.id,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
userToken = jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: role!.name },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
for (const year of [2098, 2099]) {
|
||||
await prisma.leave_balances.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
year,
|
||||
vacation_total: 200,
|
||||
vacation_used: 0,
|
||||
sick_used: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function balance(year: number) {
|
||||
return prisma.leave_balances.findFirst({
|
||||
where: { user_id: userId, year },
|
||||
});
|
||||
}
|
||||
|
||||
describe("leave requests vs Czech holidays (audit H6)", () => {
|
||||
it("creation excludes weekday public holidays from the charged total", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/leave-requests",
|
||||
headers: {
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
leave_type: "vacation",
|
||||
date_from: "2098-05-01",
|
||||
date_to: "2098-05-08",
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const row = await prisma.leave_requests.findFirst({
|
||||
where: { id: res.json().data.id },
|
||||
});
|
||||
expect(row?.total_days).toBe(4);
|
||||
expect(Number(row?.total_hours)).toBe(32);
|
||||
// Leave it cancelled so the approval test's range stays free.
|
||||
await prisma.leave_requests.update({
|
||||
where: { id: row!.id },
|
||||
data: { status: "cancelled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("approval charges only non-holiday business days and skips holiday attendance rows", async () => {
|
||||
// Stored totals mimic a PRE-FIX request (weekend-only counting) — the
|
||||
// approval must recompute, not trust them.
|
||||
const req = await prisma.leave_requests.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
leave_type: "vacation",
|
||||
date_from: new Date(Date.UTC(2098, 4, 1)),
|
||||
date_to: new Date(Date.UTC(2098, 4, 8)),
|
||||
total_hours: 48,
|
||||
total_days: 6,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/leave-requests/${req.id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { status: "approved" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
expect(Number((await balance(2098))?.vacation_used)).toBe(32);
|
||||
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { user_id: userId, leave_type: "vacation" },
|
||||
});
|
||||
const days = rows.map((r) => r.shift_date.toISOString().slice(0, 10));
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(days).not.toContain("2098-05-01");
|
||||
expect(days).not.toContain("2098-05-08");
|
||||
});
|
||||
|
||||
it("a year-spanning approval books each day against its own year's balance", async () => {
|
||||
const req = await prisma.leave_requests.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
leave_type: "sick",
|
||||
date_from: new Date(Date.UTC(2098, 11, 28)),
|
||||
date_to: new Date(Date.UTC(2099, 0, 5)),
|
||||
total_hours: 48,
|
||||
total_days: 6,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/leave-requests/${req.id}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: { status: "approved" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// 29/30/31 Dec 2098 = 24h; Jan 2 + Jan 5 2099 = 16h (Jan 1 is a holiday).
|
||||
expect(Number((await balance(2098))?.sick_used)).toBe(24);
|
||||
expect(Number((await balance(2099))?.sick_used)).toBe(16);
|
||||
});
|
||||
});
|
||||
51
src/__tests__/navdata-icons.test.ts
Normal file
51
src/__tests__/navdata-icons.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { isValidElement, type ReactElement } from "react";
|
||||
import { menuSections, type MenuItem } from "../admin/ui/navData";
|
||||
|
||||
/**
|
||||
* Pins the sidebar icon-refresh contract (2026-06-12 design): every menu
|
||||
* item has a glyph, and no two items share one — duplicated glyphs were the
|
||||
* whole problem the refresh fixed (3× "people", 3× "sliders", …). Odin's
|
||||
* rawIcon (OdinMark, MUI-styled + animated) is exempt from rendering here
|
||||
* but still must exist and stay the ONLY rawIcon.
|
||||
*/
|
||||
|
||||
const allItems: Array<MenuItem & { section: string }> = menuSections.flatMap(
|
||||
(s) => s.items.map((item) => ({ section: s.label, ...item })),
|
||||
);
|
||||
|
||||
describe("sidebar nav icons", () => {
|
||||
it("every item has an icon and a section hue", () => {
|
||||
for (const section of menuSections) {
|
||||
expect(section.hue, `section ${section.label}`).toBeTruthy();
|
||||
}
|
||||
for (const item of allItems) {
|
||||
expect(isValidElement(item.icon), `${item.section}/${item.label}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("Odin is the only rawIcon (its own tile + animation stay unique)", () => {
|
||||
const raw = allItems.filter((i) => i.rawIcon);
|
||||
expect(raw.map((i) => i.label)).toEqual(["Odin"]);
|
||||
});
|
||||
|
||||
it("no two items share a glyph", () => {
|
||||
const rendered = allItems
|
||||
.filter((i) => !i.rawIcon)
|
||||
.map((item) => ({
|
||||
key: `${item.section}/${item.label}`,
|
||||
markup: renderToStaticMarkup(item.icon as ReactElement),
|
||||
}));
|
||||
|
||||
const seen = new Map<string, string>();
|
||||
for (const { key, markup } of rendered) {
|
||||
const dupe = seen.get(markup);
|
||||
expect(dupe, `${key} shares its glyph with ${dupe}`).toBeUndefined();
|
||||
seen.set(markup, key);
|
||||
}
|
||||
expect(seen.size).toBe(rendered.length);
|
||||
});
|
||||
});
|
||||
100
src/__tests__/offer-number-release.test.ts
Normal file
100
src/__tests__/offer-number-release.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { deleteOffer } from "../services/offers.service";
|
||||
import { applyPattern } from "../services/numbering.service";
|
||||
|
||||
/**
|
||||
* Pinning test for audit finding L2: an offer created in year Y but
|
||||
* finalized (numbered) in year Y+1 must release the Y+1 sequence on delete.
|
||||
* deleteOffer used to derive the release year from created_at, so the
|
||||
* reconstructed highest number ("2025/NA/...") never matched the actual
|
||||
* "2026/NA/..." and the counter kept a permanent gap.
|
||||
*/
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
let offerId: number;
|
||||
let originalLastNumber: number | null = null; // null = row absent before test
|
||||
let offerNumber: string;
|
||||
|
||||
async function getSeq(): Promise<number | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ last_number: number }>>`
|
||||
SELECT last_number FROM number_sequences
|
||||
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||
`;
|
||||
return rows.length > 0 ? Number(rows[0].last_number) : null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const settings = await prisma.company_settings.findFirst();
|
||||
const pattern = settings?.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}";
|
||||
const prefix = settings?.quotation_prefix || "NA";
|
||||
|
||||
originalLastNumber = await getSeq();
|
||||
const base = originalLastNumber ?? 0;
|
||||
const seq = base + 1;
|
||||
offerNumber = applyPattern(pattern, {
|
||||
year: CURRENT_YEAR,
|
||||
prefix,
|
||||
code: "",
|
||||
seq,
|
||||
});
|
||||
|
||||
// Consume the number in the sequence (what finalize would have done).
|
||||
if (originalLastNumber === null) {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
|
||||
VALUES ('offer', ${CURRENT_YEAR}, ${seq})
|
||||
`;
|
||||
} else {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE number_sequences SET \`last_number\` = ${seq}
|
||||
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||
`;
|
||||
}
|
||||
|
||||
// The offer itself was CREATED last year (draft), finalized this year —
|
||||
// its number carries the finalize year, created_at the draft year.
|
||||
const offer = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: offerNumber,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
created_at: new Date(CURRENT_YEAR - 1, 11, 30, 12, 0, 0),
|
||||
},
|
||||
});
|
||||
offerId = offer.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.quotations
|
||||
.deleteMany({ where: { id: offerId } })
|
||||
.catch(() => {});
|
||||
// Restore the sequence to its pre-test value regardless of outcome.
|
||||
if (originalLastNumber === null) {
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM number_sequences
|
||||
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||
`;
|
||||
} else {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE number_sequences SET \`last_number\` = ${originalLastNumber}
|
||||
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||
`;
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("offer sequence release across years (audit L2)", () => {
|
||||
it("releases the finalize-year sequence when created_at is in a previous year", async () => {
|
||||
const before = await getSeq();
|
||||
expect(before).toBe((originalLastNumber ?? 0) + 1);
|
||||
|
||||
const result = await deleteOffer(offerId);
|
||||
expect(result && "error" in result ? result.error : null).toBeNull();
|
||||
|
||||
// The highest number was just deleted → the counter must step back.
|
||||
expect(await getSeq()).toBe(originalLastNumber ?? 0);
|
||||
});
|
||||
});
|
||||
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import customersRoutes from "../routes/admin/customers";
|
||||
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding L3: offset pagination over a non-unique
|
||||
* sort column needs an `{ id }` tiebreak, or rows sharing the sort value can
|
||||
* reorder between page queries — duplicating one row and skipping another.
|
||||
* (Documented determinism convention; siblings issued-orders/trips already
|
||||
* use compound orderings.)
|
||||
*
|
||||
* NOTE: without the tiebreak the misbehavior is probabilistic (MySQL may
|
||||
* return a stable order by luck), so the paging assertion is primarily a
|
||||
* REGRESSION GUARD pinning the exactly-once contract.
|
||||
*/
|
||||
|
||||
const N = "tiebreak_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let customerIds: number[] = [];
|
||||
let invoiceIds: number[] = [];
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.customers.deleteMany({ where: { name: { startsWith: N } } });
|
||||
await prisma.received_invoices.deleteMany({
|
||||
where: { supplier_name: { startsWith: N } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(customersRoutes, { prefix: "/api/admin/customers" });
|
||||
await app.register(receivedInvoicesRoutes, {
|
||||
prefix: "/api/admin/received-invoices",
|
||||
});
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
// 5 customers sharing one city (the sort key) so every page boundary
|
||||
// falls inside a duplicate-key run.
|
||||
customerIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: `${N}c${i}`, city: "Tiebreakov" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
}
|
||||
|
||||
// 5 received invoices sharing supplier_name in one far-future month.
|
||||
invoiceIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const inv = await prisma.received_invoices.create({
|
||||
data: {
|
||||
supplier_name: `${N}supplier`,
|
||||
month: 7,
|
||||
year: 2098,
|
||||
amount: 100 + i,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
vat_amount: 17.36,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
invoiceIds.push(inv.id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function collectPages(urlBase: string, pages: number) {
|
||||
const seen: number[] = [];
|
||||
for (let page = 1; page <= pages; page++) {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `${urlBase}&page=${page}&limit=2`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
for (const row of res.json().data as Array<{ id: number }>) {
|
||||
seen.push(row.id);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("offset pagination exactly-once (audit L3)", () => {
|
||||
it("customers sorted by a duplicate city appear exactly once across pages", async () => {
|
||||
const seen = (
|
||||
await collectPages("/api/admin/customers?sort=city&search=" + N, 3)
|
||||
).filter((id) => customerIds.includes(id));
|
||||
|
||||
expect([...new Set(seen)].sort()).toEqual([...customerIds].sort());
|
||||
expect(seen.length).toBe(customerIds.length);
|
||||
});
|
||||
|
||||
it("received invoices sorted by a duplicate supplier appear exactly once across pages", async () => {
|
||||
const seen = (
|
||||
await collectPages(
|
||||
"/api/admin/received-invoices?sort=supplier_name&month=7&year=2098",
|
||||
3,
|
||||
)
|
||||
).filter((id) => invoiceIds.includes(id));
|
||||
|
||||
expect([...new Set(seen)].sort()).toEqual([...invoiceIds].sort());
|
||||
expect(seen.length).toBe(invoiceIds.length);
|
||||
});
|
||||
});
|
||||
106
src/__tests__/plan-update-cap.test.ts
Normal file
106
src/__tests__/plan-update-cap.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { updateEntry } from "../services/plan.service";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M9: updateEntry must re-check the per-cell
|
||||
* cap (MAX_RECORDS_PER_CELL = 3) when the date range changes — expanding an
|
||||
* entry onto a day that already holds 3 active entries used to succeed,
|
||||
* producing a 4th that the grid (capped at 3) silently hides.
|
||||
*
|
||||
* The re-check must EXCLUDE the updated entry itself, so editing an at-cap
|
||||
* entry without moving it stays allowed.
|
||||
*/
|
||||
|
||||
const NOTE = "plan_updcap_test";
|
||||
|
||||
let userId: number;
|
||||
let cappedEntryIds: number[] = [];
|
||||
let fourthId: number;
|
||||
|
||||
const D = (day: number) => new Date(Date.UTC(2099, 6, day)); // July 2099, UTC
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.work_plan_entries.deleteMany({ where: { note: NOTE } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||
if (!user) throw new Error("Test setup: no users — seed the database");
|
||||
userId = user.id;
|
||||
|
||||
cappedEntryIds = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
date_from: D(1),
|
||||
date_to: D(1),
|
||||
category: "work",
|
||||
note: NOTE,
|
||||
created_by: userId,
|
||||
},
|
||||
});
|
||||
cappedEntryIds.push(entry.id);
|
||||
}
|
||||
const fourth = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
date_from: D(2),
|
||||
date_to: D(2),
|
||||
category: "work",
|
||||
note: NOTE,
|
||||
created_by: userId,
|
||||
},
|
||||
});
|
||||
fourthId = fourth.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("updateEntry per-cell cap (audit M9)", () => {
|
||||
it("rejects expanding an entry onto a day already at the cap", async () => {
|
||||
const result = await updateEntry(
|
||||
fourthId,
|
||||
{ date_from: "2099-07-01", date_to: "2099-07-01" },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(400);
|
||||
|
||||
// The entry must not have moved.
|
||||
const fresh = await prisma.work_plan_entries.findUnique({
|
||||
where: { id: fourthId },
|
||||
});
|
||||
expect(fresh?.date_from.toISOString().slice(0, 10)).toBe("2099-07-02");
|
||||
});
|
||||
|
||||
it("still allows editing an at-cap entry without moving it (self-exclusion)", async () => {
|
||||
const result = await updateEntry(
|
||||
cappedEntryIds[0],
|
||||
{ date_from: "2099-07-01", date_to: "2099-07-01", note: NOTE },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("still allows a note-only edit and a move to a free day", async () => {
|
||||
const noteOnly = await updateEntry(fourthId, { note: NOTE }, userId, false);
|
||||
expect("error" in noteOnly).toBe(false);
|
||||
|
||||
const move = await updateEntry(
|
||||
fourthId,
|
||||
{ date_from: "2099-07-03", date_to: "2099-07-03" },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
expect("error" in move).toBe(false);
|
||||
});
|
||||
});
|
||||
227
src/__tests__/project-notes.test.ts
Normal file
227
src/__tests__/project-notes.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import projectsRoutes from "../routes/admin/projects";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project notes: author-only editing with a visible edited_at stamp, and
|
||||
// admin-only deletion (a projects.edit holder may add + edit their own notes
|
||||
// but never delete — user decision 2026-06-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const N = "projnotes_test_";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminToken: string;
|
||||
let userAId: number;
|
||||
let userAToken: string;
|
||||
let userBToken: string;
|
||||
let roleId: number;
|
||||
let projectId: number;
|
||||
let noteId: 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(projectsRoutes, { prefix: "/api/admin/projects" });
|
||||
return a;
|
||||
}
|
||||
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
function inject(
|
||||
method: "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
return app.inject({
|
||||
method,
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Content-Type only with a body — Fastify 400s an empty JSON body.
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { payload: body as object } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Defensive pre-clean of a previously failed run.
|
||||
await prisma.project_notes.deleteMany({
|
||||
where: { content: { contains: N } },
|
||||
});
|
||||
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
await prisma.roles.deleteMany({ where: { name: `${N}role` } });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Non-admin role with projects.view + projects.edit.
|
||||
const role = await prisma.roles.create({
|
||||
data: { name: `${N}role`, display_name: "ProjNotes test role" },
|
||||
});
|
||||
roleId = role.id;
|
||||
const perms = await prisma.permissions.findMany({
|
||||
where: { name: { in: ["projects.view", "projects.edit"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (perms.length !== 2) {
|
||||
throw new Error("Test setup: projects.view/projects.edit missing");
|
||||
}
|
||||
await prisma.role_permissions.createMany({
|
||||
data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })),
|
||||
});
|
||||
|
||||
const mkUser = async (suffix: string) =>
|
||||
prisma.users.create({
|
||||
data: {
|
||||
username: `${N}${suffix}`,
|
||||
email: `${N}${suffix}@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Projnotes",
|
||||
last_name: suffix.toUpperCase(),
|
||||
is_active: true,
|
||||
role_id: roleId,
|
||||
},
|
||||
});
|
||||
const userA = await mkUser("a");
|
||||
const userB = await mkUser("b");
|
||||
userAId = userA.id;
|
||||
userAToken = generateToken({
|
||||
id: userA.id,
|
||||
username: userA.username,
|
||||
roleName: `${N}role`,
|
||||
});
|
||||
userBToken = generateToken({
|
||||
id: userB.id,
|
||||
username: userB.username,
|
||||
roleName: `${N}role`,
|
||||
});
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { name: `${N}project`, status: "aktivni" },
|
||||
});
|
||||
projectId = project.id;
|
||||
|
||||
// Note authored by user A (through the route, like the UI does).
|
||||
const res = await inject(
|
||||
"POST",
|
||||
`/api/admin/projects/${projectId}/notes`,
|
||||
userAToken,
|
||||
{ content: `${N}original` },
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
noteId = res.json().data.note.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.project_notes.deleteMany({ where: { project_id: projectId } });
|
||||
await prisma.projects.deleteMany({ where: { id: projectId } });
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
await prisma.roles.deleteMany({ where: { id: roleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("project notes — author-only edit with edited_at stamp", () => {
|
||||
it("the author edits their own note and the edit is stamped", async () => {
|
||||
const res = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
{ content: `${N}edited` },
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row?.content).toBe(`${N}edited`);
|
||||
expect(row?.user_id).toBe(userAId);
|
||||
expect(row?.edited_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("another user cannot edit someone else's note", async () => {
|
||||
const res = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userBToken,
|
||||
{ content: `${N}hijack` },
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row?.content).toBe(`${N}edited`); // unchanged
|
||||
});
|
||||
|
||||
it("rejects empty content and unknown note ids", async () => {
|
||||
const empty = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
{ content: " " },
|
||||
);
|
||||
expect(empty.statusCode).toBe(400);
|
||||
|
||||
const missing = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/999999999`,
|
||||
userAToken,
|
||||
{ content: "x" },
|
||||
);
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project notes — admin-only deletion", () => {
|
||||
it("a projects.edit holder cannot delete (even their own note)", async () => {
|
||||
const res = await inject(
|
||||
"DELETE",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("an admin deletes anybody's note", async () => {
|
||||
const res = await inject(
|
||||
"DELETE",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
});
|
||||
76
src/__tests__/projects-update-fk.test.ts
Normal file
76
src/__tests__/projects-update-fk.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { updateProject } from "../services/projects.service";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M6: updateProject must pre-validate FK ids
|
||||
* (customer_id, responsible_user_id, quotation_id, order_id) and return clean
|
||||
* Czech 400s like createProject does — a dangling id otherwise raises P2003
|
||||
* at Prisma and surfaces as a generic 500.
|
||||
*/
|
||||
|
||||
const N = "prj_fk_";
|
||||
|
||||
let projectId: number;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const project = await prisma.projects.create({
|
||||
data: { name: `${N}project`, status: "active" },
|
||||
});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function isError(r: unknown): r is { error: string; status: number } {
|
||||
return typeof r === "object" && r !== null && "error" in r;
|
||||
}
|
||||
|
||||
describe("updateProject FK existence checks (audit M6)", () => {
|
||||
it.each([
|
||||
["customer_id", "Zákazník nenalezen"],
|
||||
["responsible_user_id", "Zodpovědná osoba nenalezena"],
|
||||
["quotation_id", "Nabídka nenalezena"],
|
||||
["order_id", "Zakázka nenalezena"],
|
||||
])("returns a Czech 400 for a dangling %s", async (field, message) => {
|
||||
const result = await updateProject(projectId, { [field]: 99999999 });
|
||||
|
||||
expect(isError(result)).toBe(true);
|
||||
if (isError(result)) {
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toBe(message);
|
||||
}
|
||||
|
||||
// Nothing was written.
|
||||
const fresh = await prisma.projects.findUnique({
|
||||
where: { id: projectId },
|
||||
});
|
||||
expect(fresh?.[field as "customer_id"]).toBeNull();
|
||||
});
|
||||
|
||||
it("still allows clearing an FK with null", async () => {
|
||||
const result = await updateProject(projectId, { customer_id: null });
|
||||
expect(isError(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("still allows setting a valid FK", async () => {
|
||||
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||
expect(user).not.toBeNull();
|
||||
const result = await updateProject(projectId, {
|
||||
responsible_user_id: user!.id,
|
||||
});
|
||||
expect(isError(result)).toBe(false);
|
||||
const fresh = await prisma.projects.findUnique({
|
||||
where: { id: projectId },
|
||||
});
|
||||
expect(fresh?.responsible_user_id).toBe(user!.id);
|
||||
});
|
||||
});
|
||||
115
src/__tests__/received-invoice-dates.test.ts
Normal file
115
src/__tests__/received-invoice-dates.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { z } from "zod";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
|
||||
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding H5: Czech-format dates in the received-
|
||||
* invoice upload metadata must be rejected with a clean Czech 400 BEFORE any
|
||||
* NAS side effect — "15.03.2026" used to parse to Invalid Date (NaN
|
||||
* month/year written after the NAS save → 500 + orphaned file) and
|
||||
* "12.6.2026" silently filed under December (US month-first parsing).
|
||||
*/
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(receivedInvoicesRoutes, {
|
||||
prefix: "/api/admin/received-invoices",
|
||||
});
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function multipartUpload(meta: Record<string, unknown>) {
|
||||
const boundary = "----vitestboundary";
|
||||
const payload = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="invoices"',
|
||||
"",
|
||||
JSON.stringify([meta]),
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="files"; filename="test.pdf"',
|
||||
"Content-Type: application/pdf",
|
||||
"",
|
||||
"%PDF-1.4 test",
|
||||
`--${boundary}--`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/received-invoices",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("received-invoice date handling (audit H5)", () => {
|
||||
const partialSchema = z.array(CreateReceivedInvoiceSchema.partial());
|
||||
|
||||
it("schema rejects Czech-format dates", () => {
|
||||
expect(
|
||||
partialSchema.safeParse([{ issue_date: "15.03.2026" }]).success,
|
||||
).toBe(false);
|
||||
expect(partialSchema.safeParse([{ issue_date: "12.6.2026" }]).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("schema strips a trailing time part and normalizes '' to null", () => {
|
||||
const withTime = partialSchema.safeParse([
|
||||
{ issue_date: "2098-05-03T00:00:00" },
|
||||
]);
|
||||
expect(withTime.success).toBe(true);
|
||||
if (withTime.success)
|
||||
expect(withTime.data[0].issue_date).toBe("2098-05-03");
|
||||
|
||||
const empty = partialSchema.safeParse([{ issue_date: "" }]);
|
||||
expect(empty.success).toBe(true);
|
||||
if (empty.success) expect(empty.data[0].issue_date).toBeNull();
|
||||
});
|
||||
|
||||
it("upload with a Czech-format issue_date returns 400 before any NAS write", async () => {
|
||||
const res = await multipartUpload({
|
||||
supplier_name: "Test s.r.o.",
|
||||
amount: 121,
|
||||
vat_rate: 21,
|
||||
issue_date: "15.03.2026",
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("Datum");
|
||||
});
|
||||
|
||||
it("upload with an impossible regex-passing date returns 400, not NaN month", async () => {
|
||||
const res = await multipartUpload({
|
||||
supplier_name: "Test s.r.o.",
|
||||
amount: 121,
|
||||
vat_rate: 21,
|
||||
issue_date: "2098-99-99",
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("datum");
|
||||
});
|
||||
});
|
||||
394
src/__tests__/schema-caps.test.ts
Normal file
394
src/__tests__/schema-caps.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
CreateOrderSchema,
|
||||
UpdateOrderSchema,
|
||||
CreateOrderFromQuotationSchema,
|
||||
} from "../schemas/orders.schema";
|
||||
import {
|
||||
CreateInvoiceSchema,
|
||||
UpdateInvoiceSchema,
|
||||
} from "../schemas/invoices.schema";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||
import { TotpEnableSchema } from "../schemas/auth.schema";
|
||||
import {
|
||||
CreateReceivedInvoiceSchema,
|
||||
UpdateReceivedInvoiceSchema,
|
||||
} from "../schemas/received-invoices.schema";
|
||||
import {
|
||||
CreateSupplierSchema,
|
||||
UpdateSupplierSchema,
|
||||
} from "../schemas/warehouse.schema";
|
||||
import {
|
||||
CreateCustomerSchema,
|
||||
UpdateCustomerSchema,
|
||||
} from "../schemas/customers.schema";
|
||||
|
||||
/**
|
||||
* Pinning tests for the Zod-cap-vs-DB-column class (audit H2, M2, M4, M11,
|
||||
* L1, L8d, L8e + adjacent invoice item/bank caps): every form-facing string
|
||||
* cap must fit its DB column width, or an over-width value passes Zod and
|
||||
* dies at Prisma as a 500 (P2000) instead of a clean Czech 400.
|
||||
*
|
||||
* Each case asserts BOTH directions: width+1 chars must FAIL the schema, and
|
||||
* exactly width chars must PASS (guards against over-tightening — stored
|
||||
* values can never exceed the column width, so re-submitted edit forms stay
|
||||
* valid).
|
||||
*/
|
||||
|
||||
interface CapCase {
|
||||
name: string;
|
||||
schema: z.ZodTypeAny;
|
||||
/** DB column width from prisma/schema.prisma */
|
||||
width: number;
|
||||
build: (value: string) => unknown;
|
||||
}
|
||||
|
||||
const tripBase = {
|
||||
vehicle_id: 1,
|
||||
trip_date: "2098-03-02",
|
||||
start_km: 0,
|
||||
end_km: 1,
|
||||
route_from: "A",
|
||||
route_to: "B",
|
||||
};
|
||||
const receivedBase = { supplier_name: "X", month: 6, year: 2026 };
|
||||
|
||||
const CASES: CapCase[] = [
|
||||
// ── orders.schema.ts vs order_items / orders ──
|
||||
{
|
||||
name: "CreateOrderSchema items.description",
|
||||
schema: CreateOrderSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ items: [{ description: v }] }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderSchema items.unit",
|
||||
schema: CreateOrderSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ items: [{ unit: v }] }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderSchema order_number",
|
||||
schema: CreateOrderSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ order_number: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderSchema customer_order_number",
|
||||
schema: CreateOrderSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ customer_order_number: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderSchema currency",
|
||||
schema: CreateOrderSchema,
|
||||
width: 10,
|
||||
build: (v) => ({ currency: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderSchema language",
|
||||
schema: CreateOrderSchema,
|
||||
width: 5,
|
||||
build: (v) => ({ language: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateOrderSchema customer_order_number",
|
||||
schema: UpdateOrderSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ customer_order_number: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateOrderSchema currency",
|
||||
schema: UpdateOrderSchema,
|
||||
width: 10,
|
||||
build: (v) => ({ currency: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateOrderSchema language",
|
||||
schema: UpdateOrderSchema,
|
||||
width: 5,
|
||||
build: (v) => ({ language: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateOrderFromQuotationSchema customerOrderNumber",
|
||||
schema: CreateOrderFromQuotationSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ quotationId: 1, customerOrderNumber: v }),
|
||||
},
|
||||
// ── invoices.schema.ts vs invoice_items / invoices ──
|
||||
{
|
||||
name: "CreateInvoiceSchema items.description",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ items: [{ description: v }] }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema items.unit",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ items: [{ unit: v }] }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema invoice_number",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ invoice_number: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema currency",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 10,
|
||||
build: (v) => ({ currency: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema language",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 5,
|
||||
build: (v) => ({ language: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema payment_method",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ payment_method: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema constant_symbol",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ constant_symbol: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema bank_swift",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ bank_swift: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema bank_iban",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ bank_iban: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema bank_account",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ bank_account: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateInvoiceSchema billing_text",
|
||||
schema: CreateInvoiceSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ billing_text: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema currency",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 10,
|
||||
build: (v) => ({ currency: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema language",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 5,
|
||||
build: (v) => ({ language: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema payment_method",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ payment_method: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema constant_symbol",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ constant_symbol: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema bank_swift",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ bank_swift: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema bank_iban",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ bank_iban: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema bank_account",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ bank_account: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateInvoiceSchema billing_text",
|
||||
schema: UpdateInvoiceSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ billing_text: v }),
|
||||
},
|
||||
// ── trips.schema.ts vs trips ──
|
||||
{
|
||||
name: "CreateTripSchema route_from",
|
||||
schema: CreateTripSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ ...tripBase, route_from: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateTripSchema route_to",
|
||||
schema: CreateTripSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ ...tripBase, route_to: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateTripSchema route_from",
|
||||
schema: UpdateTripSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ route_from: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateTripSchema route_to",
|
||||
schema: UpdateTripSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ route_to: v }),
|
||||
},
|
||||
// ── auth.schema.ts vs users.totp_secret ──
|
||||
// The stored value is AES-256-GCM ciphertext (base64(IV+ct+tag)) of the
|
||||
// plaintext secret in a VarChar(255) column: a plaintext over ~164 chars
|
||||
// overflows after encryption. Real TOTP secrets are 16-32 chars; cap 64.
|
||||
{
|
||||
name: "TotpEnableSchema secret",
|
||||
schema: TotpEnableSchema,
|
||||
width: 64,
|
||||
build: (v) => ({ secret: v, code: "123456" }),
|
||||
},
|
||||
// ── received-invoices.schema.ts vs received_invoices ──
|
||||
{
|
||||
name: "CreateReceivedInvoiceSchema description",
|
||||
schema: CreateReceivedInvoiceSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ ...receivedBase, description: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateReceivedInvoiceSchema invoice_number",
|
||||
schema: CreateReceivedInvoiceSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ ...receivedBase, invoice_number: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateReceivedInvoiceSchema currency",
|
||||
schema: CreateReceivedInvoiceSchema,
|
||||
width: 3,
|
||||
build: (v) => ({ ...receivedBase, currency: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateReceivedInvoiceSchema description",
|
||||
schema: UpdateReceivedInvoiceSchema,
|
||||
width: 500,
|
||||
build: (v) => ({ description: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateReceivedInvoiceSchema invoice_number",
|
||||
schema: UpdateReceivedInvoiceSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ invoice_number: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateReceivedInvoiceSchema currency",
|
||||
schema: UpdateReceivedInvoiceSchema,
|
||||
width: 3,
|
||||
build: (v) => ({ currency: v }),
|
||||
},
|
||||
// ── warehouse.schema.ts (suppliers) vs sklad_suppliers ──
|
||||
{
|
||||
name: "CreateSupplierSchema ico",
|
||||
schema: CreateSupplierSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ name: "X", ico: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateSupplierSchema dic",
|
||||
schema: CreateSupplierSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ name: "X", dic: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateSupplierSchema ico",
|
||||
schema: UpdateSupplierSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ ico: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateSupplierSchema dic",
|
||||
schema: UpdateSupplierSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ dic: v }),
|
||||
},
|
||||
// ── customers.schema.ts vs customers ──
|
||||
{
|
||||
name: "CreateCustomerSchema company_id",
|
||||
schema: CreateCustomerSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ name: "X", company_id: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateCustomerSchema vat_id",
|
||||
schema: CreateCustomerSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ name: "X", vat_id: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateCustomerSchema postal_code",
|
||||
schema: CreateCustomerSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ name: "X", postal_code: v }),
|
||||
},
|
||||
{
|
||||
name: "CreateCustomerSchema country",
|
||||
schema: CreateCustomerSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ name: "X", country: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateCustomerSchema company_id",
|
||||
schema: UpdateCustomerSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ company_id: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateCustomerSchema vat_id",
|
||||
schema: UpdateCustomerSchema,
|
||||
width: 50,
|
||||
build: (v) => ({ vat_id: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateCustomerSchema postal_code",
|
||||
schema: UpdateCustomerSchema,
|
||||
width: 20,
|
||||
build: (v) => ({ postal_code: v }),
|
||||
},
|
||||
{
|
||||
name: "UpdateCustomerSchema country",
|
||||
schema: UpdateCustomerSchema,
|
||||
width: 100,
|
||||
build: (v) => ({ country: v }),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Zod caps fit DB column widths (audit H2/M2/M4/M11/L1/L8d/L8e + adjacent)", () => {
|
||||
for (const c of CASES) {
|
||||
it(`${c.name} rejects ${c.width + 1} chars`, () => {
|
||||
const result = c.schema.safeParse(c.build("x".repeat(c.width + 1)));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it(`${c.name} accepts ${c.width} chars`, () => {
|
||||
const result = c.schema.safeParse(c.build("x".repeat(c.width)));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -85,11 +85,13 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
|
||||
describe("CreateTripSchema", () => {
|
||||
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
||||
// km fields are Int columns — integer strings coerce, decimals are
|
||||
// rejected (see trips-vehicles-km-int.test.ts for the rejection pins).
|
||||
const result = CreateTripSchema.safeParse({
|
||||
vehicle_id: "5",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "100",
|
||||
end_km: "150.5",
|
||||
end_km: "150",
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
@@ -97,7 +99,7 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
if (result.success) {
|
||||
expect(result.data.vehicle_id).toBe(5);
|
||||
expect(result.data.start_km).toBe(100);
|
||||
expect(result.data.end_km).toBe(150.5);
|
||||
expect(result.data.end_km).toBe(150);
|
||||
expect(typeof result.data.start_km).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
138
src/__tests__/session-terminate-refresh.test.ts
Normal file
138
src/__tests__/session-terminate-refresh.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import prisma from "../config/database";
|
||||
import { refreshAccessToken, hashToken } from "../services/auth";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding H1: a session terminated via the sessions
|
||||
* routes (replaced_at set, NO replaced_by_hash) presented on a later refresh
|
||||
* is NOT token theft — it must be rejected with a plain 401 WITHOUT revoking
|
||||
* the user's other sessions. Real theft (replay of a ROTATED token, i.e.
|
||||
* replaced_by_hash set) must keep revoking the whole family.
|
||||
*/
|
||||
|
||||
const N = "sess_term_";
|
||||
const stamp = Date.now().toString(36);
|
||||
|
||||
const fakeReq = {
|
||||
log: { warn: () => {} },
|
||||
ip: "127.0.0.1",
|
||||
headers: {},
|
||||
} as unknown as FastifyRequest;
|
||||
|
||||
let userId: number;
|
||||
|
||||
const rawA = `${N}rawA_${stamp}`;
|
||||
const rawB = `${N}rawB_${stamp}`;
|
||||
const rawC = `${N}rawC_${stamp}`;
|
||||
const rawD = `${N}rawD_${stamp}`;
|
||||
|
||||
let tokenAId: number;
|
||||
let tokenDId: number;
|
||||
|
||||
async function cleanup() {
|
||||
const users = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = users.map((u) => u.id);
|
||||
if (ids.length > 0) {
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user_${stamp}`,
|
||||
email: `${N}${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
first_name: "Session",
|
||||
last_name: "Terminate",
|
||||
is_active: true,
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const future = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
||||
const tokenA = await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hashToken(rawA),
|
||||
expires_at: future,
|
||||
remember_me: false,
|
||||
},
|
||||
});
|
||||
tokenAId = tokenA.id;
|
||||
// Token B: terminated the way DELETE /sessions/:id does it — replaced_at
|
||||
// ONLY, no replaced_by_hash, row kept.
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hashToken(rawB),
|
||||
expires_at: future,
|
||||
remember_me: false,
|
||||
replaced_at: new Date(),
|
||||
},
|
||||
});
|
||||
// Token C: legitimately ROTATED (both replaced_at and replaced_by_hash set)
|
||||
// — replaying it is the theft signature.
|
||||
await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hashToken(rawC),
|
||||
expires_at: future,
|
||||
remember_me: false,
|
||||
replaced_at: new Date(),
|
||||
replaced_by_hash: hashToken(`${N}descendant_${stamp}`),
|
||||
},
|
||||
});
|
||||
const tokenD = await prisma.refresh_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hashToken(rawD),
|
||||
expires_at: future,
|
||||
remember_me: false,
|
||||
},
|
||||
});
|
||||
tokenDId = tokenD.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("refreshAccessToken vs terminated sessions (audit H1)", () => {
|
||||
it("rejects a manually terminated token with 401 WITHOUT revoking other sessions", async () => {
|
||||
const result = await refreshAccessToken(rawB, fakeReq);
|
||||
|
||||
expect(result.type).toBe("error");
|
||||
if (result.type === "error") expect(result.status).toBe(401);
|
||||
|
||||
// The user's OTHER session must survive — termination is not theft.
|
||||
const tokenA = await prisma.refresh_tokens.findUnique({
|
||||
where: { id: tokenAId },
|
||||
});
|
||||
expect(tokenA).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still revokes the whole family on replay of a ROTATED token (theft)", async () => {
|
||||
const result = await refreshAccessToken(rawC, fakeReq);
|
||||
|
||||
expect(result.type).toBe("error");
|
||||
|
||||
// Breach containment must keep working: every session of the user is
|
||||
// gone, including the otherwise-valid token D.
|
||||
const tokenD = await prisma.refresh_tokens.findUnique({
|
||||
where: { id: tokenDId },
|
||||
});
|
||||
expect(tokenD).toBeNull();
|
||||
});
|
||||
});
|
||||
63
src/__tests__/trips-vehicles-km-int.test.ts
Normal file
63
src/__tests__/trips-vehicles-km-int.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||
import {
|
||||
CreateVehicleSchema,
|
||||
UpdateVehicleSchema,
|
||||
} from "../schemas/vehicles.schema";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding L7: odometer fields are Int columns —
|
||||
* a decimal reading must 400 at Zod instead of 500ing at Prisma (or
|
||||
* silently truncating, corrupting the derived distance and
|
||||
* vehicles.actual_km).
|
||||
*/
|
||||
|
||||
const tripBase = {
|
||||
vehicle_id: 1,
|
||||
trip_date: "2098-01-15",
|
||||
start_km: 100,
|
||||
end_km: 200,
|
||||
route_from: "A",
|
||||
route_to: "B",
|
||||
};
|
||||
|
||||
describe("km fields are integers (audit L7)", () => {
|
||||
it.each([["start_km"], ["end_km"]])(
|
||||
"CreateTripSchema rejects a decimal %s",
|
||||
(field) => {
|
||||
expect(
|
||||
CreateTripSchema.safeParse({ ...tripBase, [field]: 100.5 }).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("CreateTripSchema still accepts integer readings", () => {
|
||||
expect(CreateTripSchema.safeParse(tripBase).success).toBe(true);
|
||||
});
|
||||
|
||||
it("UpdateTripSchema rejects a decimal start_km", () => {
|
||||
expect(UpdateTripSchema.safeParse({ start_km: 100.5 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([["initial_km"], ["actual_km"]])(
|
||||
"CreateVehicleSchema rejects a decimal %s",
|
||||
(field) => {
|
||||
expect(
|
||||
CreateVehicleSchema.safeParse({
|
||||
spz: "1AB 1234",
|
||||
name: "Test",
|
||||
[field]: 12345.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("UpdateVehicleSchema rejects a decimal actual_km, accepts an integer", () => {
|
||||
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345.5 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
165
src/__tests__/users-create-role-guard.test.ts
Normal file
165
src/__tests__/users-create-role-guard.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import usersRoutes from "../routes/admin/users";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M10: POST /users must strip role_id for
|
||||
* non-admin callers (mirroring the PUT privilege-escalation guard) — a bare
|
||||
* users.create holder must not be able to mint an account on a privileged
|
||||
* role the caller itself lacks.
|
||||
*/
|
||||
|
||||
const N = "usr_roleguard_";
|
||||
const stamp = Date.now().toString(36);
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let creatorToken: string;
|
||||
let creatorRoleId: number;
|
||||
let privilegedRoleId: number;
|
||||
|
||||
function token(user: { id: number; username: string; roleName: string }) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
const roles = await prisma.roles.findMany({
|
||||
where: { name: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
const roleIds = roles.map((r) => r.id);
|
||||
if (roleIds.length > 0) {
|
||||
await prisma.role_permissions.deleteMany({
|
||||
where: { role_id: { in: roleIds } },
|
||||
});
|
||||
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
await app.register(usersRoutes, { prefix: "/api/admin/users" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = token({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: "admin",
|
||||
});
|
||||
|
||||
// Caller role: ONLY users.create.
|
||||
const creatorRole = await prisma.roles.create({
|
||||
data: { name: `${N}creator_${stamp}`, display_name: "Creator Only" },
|
||||
});
|
||||
creatorRoleId = creatorRole.id;
|
||||
const perm = await prisma.permissions.findFirst({
|
||||
where: { name: "users.create" },
|
||||
});
|
||||
if (!perm) throw new Error("Test setup: users.create permission missing");
|
||||
await prisma.role_permissions.create({
|
||||
data: { role_id: creatorRoleId, permission_id: perm.id },
|
||||
});
|
||||
const creator = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}creator_${stamp}`,
|
||||
email: `${N}creator_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
first_name: "Creator",
|
||||
last_name: "Only",
|
||||
is_active: true,
|
||||
role_id: creatorRoleId,
|
||||
},
|
||||
});
|
||||
creatorToken = token({
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
roleName: creatorRole.name,
|
||||
});
|
||||
|
||||
// Target role the caller does NOT hold — its content is irrelevant, the
|
||||
// guard must strip ANY role assignment from a non-admin create.
|
||||
const privilegedRole = await prisma.roles.create({
|
||||
data: { name: `${N}privileged_${stamp}`, display_name: "Privileged" },
|
||||
});
|
||||
privilegedRoleId = privilegedRole.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /users role escalation guard (audit M10)", () => {
|
||||
it("strips role_id when the caller is not admin", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/users",
|
||||
headers: {
|
||||
Authorization: `Bearer ${creatorToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
username: `${N}victim_${stamp}`,
|
||||
email: `${N}victim_${stamp}@test.local`,
|
||||
password: "Heslo12345",
|
||||
first_name: "Victim",
|
||||
last_name: "User",
|
||||
role_id: privilegedRoleId,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const created = await prisma.users.findFirst({
|
||||
where: { username: `${N}victim_${stamp}` },
|
||||
});
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.role_id).not.toBe(privilegedRoleId);
|
||||
});
|
||||
|
||||
it("keeps role assignment for an admin caller", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/users",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
username: `${N}byadmin_${stamp}`,
|
||||
email: `${N}byadmin_${stamp}@test.local`,
|
||||
password: "Heslo12345",
|
||||
first_name: "ByAdmin",
|
||||
last_name: "User",
|
||||
role_id: privilegedRoleId,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const created = await prisma.users.findFirst({
|
||||
where: { username: `${N}byadmin_${stamp}` },
|
||||
});
|
||||
expect(created?.role_id).toBe(privilegedRoleId);
|
||||
});
|
||||
});
|
||||
92
src/__tests__/warehouse-date-filter.test.ts
Normal file
92
src/__tests__/warehouse-date-filter.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M7: date_from/date_to filters on warehouse
|
||||
* lists must cover the FULL local calendar days. created_at holds local-time
|
||||
* instants; the old bounds (gte/lte `new Date("YYYY-MM-DD")` = UTC midnight)
|
||||
* excluded the whole inclusive end day and the first 1-2 morning hours of
|
||||
* the start day.
|
||||
*/
|
||||
|
||||
const N = "wh_datefilter_";
|
||||
const DAY = "2098-04-08";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let earlyId: number; // 00:30 local on DAY — start-day morning window
|
||||
let middayId: number; // 09:00 local on DAY
|
||||
let lateId: number; // 23:30 local on DAY — end-day coverage
|
||||
let nextDayId: number; // 00:30 local the day AFTER — must stay excluded
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
// Local-time instants on the filtered day (year 2098 keeps real data out).
|
||||
const early = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}early`, created_at: new Date(2098, 3, 8, 0, 30, 0) },
|
||||
});
|
||||
earlyId = early.id;
|
||||
const midday = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}midday`, created_at: new Date(2098, 3, 8, 9, 0, 0) },
|
||||
});
|
||||
middayId = midday.id;
|
||||
const late = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}late`, created_at: new Date(2098, 3, 8, 23, 30, 0) },
|
||||
});
|
||||
lateId = late.id;
|
||||
const nextDay = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}nextday`, created_at: new Date(2098, 3, 9, 0, 30, 0) },
|
||||
});
|
||||
nextDayId = nextDay.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("warehouse receipts date filter (audit M7)", () => {
|
||||
it("a single-day range returns every receipt of that local day and nothing else", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts?date_from=${DAY}&date_to=${DAY}&limit=100`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||
|
||||
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
|
||||
expect(ids).toContain(middayId); // 09:00 — dropped by the old lte to-bound
|
||||
expect(ids).toContain(lateId); // 23:30 — dropped by the old lte to-bound
|
||||
expect(ids).not.toContain(nextDayId); // next day stays out
|
||||
});
|
||||
});
|
||||
206
src/__tests__/warehouse-fk-400.test.ts
Normal file
206
src/__tests__/warehouse-fk-400.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M8: warehouse receipts/issues create+update
|
||||
* must pre-validate FK existence (supplier, item, location, project, explicit
|
||||
* batch) and return Czech 400s — a dangling id otherwise raises P2003 at
|
||||
* Prisma and surfaces as a generic 500. The document modules and trips.ts
|
||||
* pre-validate this way; warehouse omitted the guard.
|
||||
*/
|
||||
|
||||
const N = "wh_fk400_";
|
||||
const MISSING_ID = 99999999;
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let projectId: number;
|
||||
let itemId: number;
|
||||
let draftReceiptId: number;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.sklad_issue_lines.deleteMany({
|
||||
where: { issue: { notes: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
|
||||
await prisma.sklad_batches.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipt_lines.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { name: `${N}project`, status: "active" },
|
||||
});
|
||||
projectId = project.id;
|
||||
|
||||
const item = await prisma.sklad_items.create({
|
||||
data: { name: `${N}item`, unit: "ks" },
|
||||
});
|
||||
itemId = item.id;
|
||||
|
||||
// In-stock batch so issue tests get past availability checks.
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}stock`, status: "CONFIRMED" },
|
||||
});
|
||||
const line = await prisma.sklad_receipt_lines.create({
|
||||
data: {
|
||||
receipt_id: receipt.id,
|
||||
item_id: itemId,
|
||||
quantity: 10,
|
||||
unit_price: 5,
|
||||
},
|
||||
});
|
||||
await prisma.sklad_batches.create({
|
||||
data: {
|
||||
item_id: itemId,
|
||||
receipt_line_id: line.id,
|
||||
quantity: 10,
|
||||
original_qty: 10,
|
||||
unit_price: 5,
|
||||
received_at: new Date(2026, 0, 1, 12, 0, 0),
|
||||
is_consumed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// DRAFT receipt for the PUT test.
|
||||
const draft = await prisma.sklad_receipts.create({
|
||||
data: {
|
||||
notes: `${N}draft`,
|
||||
status: "DRAFT",
|
||||
items: { create: [{ item_id: itemId, quantity: 1, unit_price: 5 }] },
|
||||
},
|
||||
});
|
||||
draftReceiptId = draft.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function post(url: string, payload: unknown) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: payload as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("warehouse FK pre-validation (audit M8)", () => {
|
||||
it("POST /receipts with a nonexistent item_id returns 400, not 500", async () => {
|
||||
const res = await post("/api/admin/warehouse/receipts", {
|
||||
notes: `${N}r1`,
|
||||
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("POST /receipts with a nonexistent supplier_id returns 400, not 500", async () => {
|
||||
const res = await post("/api/admin/warehouse/receipts", {
|
||||
notes: `${N}r2`,
|
||||
supplier_id: MISSING_ID,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("POST /receipts with a nonexistent location_id returns 400, not 500", async () => {
|
||||
const res = await post("/api/admin/warehouse/receipts", {
|
||||
notes: `${N}r3`,
|
||||
items: [
|
||||
{
|
||||
item_id: itemId,
|
||||
quantity: 1,
|
||||
unit_price: 10,
|
||||
location_id: MISSING_ID,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("PUT /receipts/:id with a nonexistent item_id returns 400, not 500", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/warehouse/receipts/${draftReceiptId}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
notes: `${N}draft`,
|
||||
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("POST /issues with a nonexistent project_id returns 400, not 500", async () => {
|
||||
const res = await post("/api/admin/warehouse/issues", {
|
||||
notes: `${N}i1`,
|
||||
project_id: MISSING_ID,
|
||||
items: [{ item_id: itemId, quantity: 1 }],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("POST /issues with a nonexistent explicit batch_id returns 400, not 500", async () => {
|
||||
const res = await post("/api/admin/warehouse/issues", {
|
||||
notes: `${N}i2`,
|
||||
project_id: projectId,
|
||||
items: [{ item_id: itemId, quantity: 1, batch_id: MISSING_ID }],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain("nenalezen");
|
||||
});
|
||||
|
||||
it("valid receipt create still succeeds", async () => {
|
||||
const res = await post("/api/admin/warehouse/receipts", {
|
||||
notes: `${N}ok`,
|
||||
items: [{ item_id: itemId, quantity: 2, unit_price: 10 }],
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
124
src/__tests__/warehouse-inventory-confirm.test.ts
Normal file
124
src/__tests__/warehouse-inventory-confirm.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { confirmInventorySession } from "../services/warehouse.service";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding C3: confirmInventorySession must be atomic.
|
||||
* A session whose item list contains a valid surplus line BEFORE a deficit
|
||||
* line that cannot be satisfied (no batches) must fail WITHOUT committing the
|
||||
* surplus item's corrective receipt/batch — and retries must not accumulate
|
||||
* phantom stock.
|
||||
*/
|
||||
|
||||
const N = "wh_invconf_";
|
||||
|
||||
let itemAId: number; // surplus line (processed first — lower line id)
|
||||
let itemBId: number; // deficit line with no batches -> selectFifoBatches throws
|
||||
let sessionId: number;
|
||||
|
||||
/** Corrective receipts carry generated notes without our prefix — collect
|
||||
* them via their lines' items before deleting. */
|
||||
async function cleanup() {
|
||||
const lines = await prisma.sklad_receipt_lines.findMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
select: { receipt_id: true },
|
||||
});
|
||||
const receiptIds = [...new Set(lines.map((l) => l.receipt_id))];
|
||||
await prisma.sklad_batches.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipt_lines.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
if (receiptIds.length > 0) {
|
||||
await prisma.sklad_receipts.deleteMany({
|
||||
where: { id: { in: receiptIds } },
|
||||
});
|
||||
}
|
||||
await prisma.sklad_inventory_lines.deleteMany({
|
||||
where: { session: { notes: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_inventory_sessions.deleteMany({
|
||||
where: { notes: { contains: N } },
|
||||
});
|
||||
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
const itemA = await prisma.sklad_items.create({
|
||||
data: { name: `${N}surplus_item`, unit: "ks" },
|
||||
});
|
||||
itemAId = itemA.id;
|
||||
const itemB = await prisma.sklad_items.create({
|
||||
data: { name: `${N}deficit_item`, unit: "ks" },
|
||||
});
|
||||
itemBId = itemB.id;
|
||||
|
||||
// Surplus line created first => lower id => processed first by the confirm
|
||||
// loop. Deficit line for an item with NO batches forces the FIFO throw.
|
||||
const session = await prisma.sklad_inventory_sessions.create({
|
||||
data: {
|
||||
notes: `${N}session`,
|
||||
items: {
|
||||
create: [
|
||||
{ item_id: itemAId, system_qty: 0, actual_qty: 5, difference: 5 },
|
||||
{ item_id: itemBId, system_qty: 3, actual_qty: 0, difference: -3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
sessionId = session.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("confirmInventorySession atomicity (audit C3)", () => {
|
||||
it("rolls back the surplus corrective receipt when a later deficit line fails", async () => {
|
||||
const result = await confirmInventorySession(sessionId);
|
||||
|
||||
// The confirm must fail — item B has nothing to consume.
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toContain(`ID ${itemBId}`);
|
||||
}
|
||||
|
||||
// ...and item A's surplus writes must NOT have been committed.
|
||||
const receiptLines = await prisma.sklad_receipt_lines.findMany({
|
||||
where: { item_id: itemAId },
|
||||
});
|
||||
expect(receiptLines).toHaveLength(0);
|
||||
|
||||
const batches = await prisma.sklad_batches.findMany({
|
||||
where: { item_id: itemAId },
|
||||
});
|
||||
expect(batches).toHaveLength(0);
|
||||
|
||||
// The session itself stays DRAFT and unnumbered either way.
|
||||
const session = await prisma.sklad_inventory_sessions.findUnique({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(session?.status).toBe("DRAFT");
|
||||
expect(session?.session_number).toBeNull();
|
||||
});
|
||||
|
||||
it("does not accumulate phantom stock when the failed confirm is retried", async () => {
|
||||
await confirmInventorySession(sessionId);
|
||||
await confirmInventorySession(sessionId);
|
||||
|
||||
const batches = await prisma.sklad_batches.findMany({
|
||||
where: { item_id: itemAId },
|
||||
});
|
||||
expect(batches).toHaveLength(0);
|
||||
|
||||
const receipts = await prisma.sklad_receipts.findMany({
|
||||
where: { notes: { contains: `inventarizace #${sessionId}` } },
|
||||
});
|
||||
expect(receipts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
196
src/__tests__/warehouse-issue-confirm.test.ts
Normal file
196
src/__tests__/warehouse-issue-confirm.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { confirmIssue } from "../services/warehouse.service";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding C2: confirmIssue must validate duplicate
|
||||
* lines pointing at the SAME batch against their combined quantity. Two lines
|
||||
* that each pass the per-line check individually must not decrement the batch
|
||||
* cumulatively below zero (silent over-issue hidden from stock totals).
|
||||
*
|
||||
* The duplicate-lines draft is exactly what POST /issues produces when two
|
||||
* same-item lines without batch_id FIFO-resolve to the same oldest batch
|
||||
* (per-line resolution persists nothing between lines), and is equally
|
||||
* reachable with explicit batch_ids.
|
||||
*/
|
||||
|
||||
const N = "wh_issueconf_";
|
||||
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
let itemId: number;
|
||||
let batch1Id: number;
|
||||
let overIssueId: number;
|
||||
let exactIssueId: number;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.sklad_issue_lines.deleteMany({
|
||||
where: { issue: { notes: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
|
||||
await prisma.sklad_batches.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipt_lines.deleteMany({
|
||||
where: { item: { name: { contains: N } } },
|
||||
});
|
||||
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await prisma.projects
|
||||
.deleteMany({ where: { name: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||
if (!adminRole)
|
||||
throw new Error("Admin role not found — seed the database first");
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `${N}user`,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Issue",
|
||||
last_name: "Confirm",
|
||||
is_active: true,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { name: `${N}project`, status: "active" },
|
||||
});
|
||||
projectId = project.id;
|
||||
|
||||
const item = await prisma.sklad_items.create({
|
||||
data: { name: `${N}item`, unit: "ks" },
|
||||
});
|
||||
itemId = item.id;
|
||||
|
||||
// Two batches of 8 — total stock 16, so the per-item aggregate check
|
||||
// passes a combined quantity of 10, but FIFO resolves both lines to the
|
||||
// OLDER batch B1 which only holds 8.
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: { notes: `${N}receipt`, status: "CONFIRMED" },
|
||||
});
|
||||
const line1 = await prisma.sklad_receipt_lines.create({
|
||||
data: {
|
||||
receipt_id: receipt.id,
|
||||
item_id: itemId,
|
||||
quantity: 8,
|
||||
unit_price: 10,
|
||||
},
|
||||
});
|
||||
const line2 = await prisma.sklad_receipt_lines.create({
|
||||
data: {
|
||||
receipt_id: receipt.id,
|
||||
item_id: itemId,
|
||||
quantity: 8,
|
||||
unit_price: 10,
|
||||
},
|
||||
});
|
||||
const batch1 = await prisma.sklad_batches.create({
|
||||
data: {
|
||||
item_id: itemId,
|
||||
receipt_line_id: line1.id,
|
||||
quantity: 8,
|
||||
original_qty: 8,
|
||||
unit_price: 10,
|
||||
received_at: new Date(2026, 0, 1, 12, 0, 0),
|
||||
is_consumed: false,
|
||||
},
|
||||
});
|
||||
batch1Id = batch1.id;
|
||||
await prisma.sklad_batches.create({
|
||||
data: {
|
||||
item_id: itemId,
|
||||
receipt_line_id: line2.id,
|
||||
quantity: 8,
|
||||
original_qty: 8,
|
||||
unit_price: 10,
|
||||
received_at: new Date(2026, 0, 2, 12, 0, 0),
|
||||
is_consumed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Draft mirroring the FIFO resolution: two lines, both on B1, 5 + 5 = 10 > 8.
|
||||
const overIssue = await prisma.sklad_issues.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
notes: `${N}over_issue`,
|
||||
items: {
|
||||
create: [
|
||||
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
overIssueId = overIssue.id;
|
||||
|
||||
// Control draft: duplicate lines whose combined quantity EXACTLY fits B1.
|
||||
const exactIssue = await prisma.sklad_issues.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
notes: `${N}exact_issue`,
|
||||
items: {
|
||||
create: [
|
||||
{ item_id: itemId, batch_id: batch1Id, quantity: 3 },
|
||||
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
exactIssueId = exactIssue.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("confirmIssue cross-line batch totals (audit C2)", () => {
|
||||
it("rejects duplicate same-batch lines whose combined quantity exceeds the batch", async () => {
|
||||
const result = await confirmIssue(overIssueId, userId);
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(400);
|
||||
|
||||
// The batch must be untouched — not driven negative and hidden.
|
||||
const b1 = await prisma.sklad_batches.findUnique({
|
||||
where: { id: batch1Id },
|
||||
});
|
||||
expect(Number(b1?.quantity)).toBe(8);
|
||||
expect(b1?.is_consumed).toBe(false);
|
||||
|
||||
const negatives = await prisma.sklad_batches.findMany({
|
||||
where: { item_id: itemId, quantity: { lt: 0 } },
|
||||
});
|
||||
expect(negatives).toHaveLength(0);
|
||||
|
||||
const issue = await prisma.sklad_issues.findUnique({
|
||||
where: { id: overIssueId },
|
||||
});
|
||||
expect(issue?.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("still confirms duplicate same-batch lines whose combined quantity fits", async () => {
|
||||
const result = await confirmIssue(exactIssueId, userId);
|
||||
|
||||
expect("error" in result).toBe(false);
|
||||
|
||||
const b1 = await prisma.sklad_batches.findUnique({
|
||||
where: { id: batch1Id },
|
||||
});
|
||||
expect(Number(b1?.quantity)).toBe(0);
|
||||
expect(b1?.is_consumed).toBe(true);
|
||||
});
|
||||
});
|
||||
97
src/__tests__/warehouse-items-sort.test.ts
Normal file
97
src/__tests__/warehouse-items-sort.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, 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 { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
|
||||
/**
|
||||
* Pinning tests for the verified-adjacent audit finding: GET /items ignores
|
||||
* the client `sort` param (the WarehouseItems page sends one — default
|
||||
* item_number) and hardcodes `orderBy: { name: "asc" }` with no `{ id }`
|
||||
* tiebreak.
|
||||
*/
|
||||
|
||||
const N = "wh_itemsort_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
// Names sort A→B, item numbers sort the OPPOSITE way, so honoring the sort
|
||||
// param produces a different first row than the hardcoded name ordering.
|
||||
let itemAId: number; // name "...a_name", item_number "...Z2"
|
||||
let itemBId: number; // name "...b_name", item_number "...A1"
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
app.addHook("onRequest", securityHeaders);
|
||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
const itemA = await prisma.sklad_items.create({
|
||||
data: { name: `${N}a_name`, item_number: `${N}Z2`, unit: "ks" },
|
||||
});
|
||||
itemAId = itemA.id;
|
||||
const itemB = await prisma.sklad_items.create({
|
||||
data: { name: `${N}b_name`, item_number: `${N}A1`, unit: "ks" },
|
||||
});
|
||||
itemBId = itemB.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("GET /warehouse/items sort param", () => {
|
||||
it("honors sort=item_number (asc puts the A1 item first)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/items?search=${N}&sort=item_number&order=asc`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||
expect(ids).toEqual([itemBId, itemAId]);
|
||||
});
|
||||
|
||||
it("defaults to name ordering when no sort is given", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/items?search=${N}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||
expect(ids).toEqual([itemAId, itemBId]);
|
||||
});
|
||||
|
||||
it("ignores a non-allow-listed sort field (no 500)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/items?search=${N}&sort=total_quantity`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ export default function BulkPlanModal({
|
||||
value={form.project_id}
|
||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
249
src/admin/components/NoteCard.tsx
Normal file
249
src/admin/components/NoteCard.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
/**
|
||||
* Chat-bubble style note row (author avatar + bubble), shared by the
|
||||
* project notes list. Mobile-first: the header wraps (name / timestamp),
|
||||
* the action icons live in the bubble's top-right corner so they never
|
||||
* squeeze the content column, and editing happens inline in the bubble.
|
||||
*/
|
||||
|
||||
const initialsOf = (name: string): string =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
|
||||
// Same edit/delete glyphs as the data tables (AttendanceShiftTable & co.).
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function formatNoteDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
export interface NoteCardProps {
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
editedAt?: string | null;
|
||||
content: string;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
deleting?: boolean;
|
||||
/** Reject (throw) to keep the editor open — the caller shows the alert. */
|
||||
onSave?: (content: string) => Promise<void>;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function NoteCard({
|
||||
authorName,
|
||||
createdAt,
|
||||
editedAt,
|
||||
content,
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
deleting = false,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: NoteCardProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(content);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!onSave || !draft.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(draft.trim());
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// Caller already alerted; keep the editor open with the draft.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={(t) => ({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.12)`,
|
||||
color: "primary.main",
|
||||
})}
|
||||
>
|
||||
{initialsOf(authorName)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
borderTopLeftRadius: 4,
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
columnGap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{authorName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(createdAt)}
|
||||
{editedAt && (
|
||||
<Box component="span" sx={{ fontStyle: "italic" }}>
|
||||
{" "}
|
||||
· upraveno {formatNoteDate(editedAt)}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!editing && (canEdit || canDelete) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.25,
|
||||
flexShrink: 0,
|
||||
mt: -0.5,
|
||||
mr: -0.5,
|
||||
}}
|
||||
>
|
||||
{canEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={startEdit}
|
||||
title="Upravit poznámku"
|
||||
aria-label="Upravit poznámku"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deleting}
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{editing ? (
|
||||
<Box>
|
||||
<TextField
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
fullWidth
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey && draft.trim()) save();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={save}
|
||||
disabled={saving || !draft.trim()}
|
||||
>
|
||||
{saving ? "Ukládání…" : "Uložit"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{content}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -392,7 +392,7 @@ function EditForm(props: Props) {
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
|
||||
@@ -157,7 +157,7 @@ function ProjectLogRow({
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
@@ -179,10 +179,12 @@ export default function DashQuickActions({
|
||||
});
|
||||
const result: ApiResult<unknown> = await response.json();
|
||||
if (result.success) {
|
||||
// A new trip changes the trip list and the dashboard vehicle widgets —
|
||||
// invalidate the broad domains (prefix-matching covers sub-queries).
|
||||
// A new trip changes the trip list, the dashboard vehicle widgets AND
|
||||
// the vehicles domain (actual_km moves) — invalidate all three broad
|
||||
// domains (prefix-matching covers sub-queries).
|
||||
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
setShowTripModal(false);
|
||||
alert.success(result.message ?? "Jízda uložena");
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
|
||||
export interface ProjectNote {
|
||||
id: number;
|
||||
content: string;
|
||||
user_id: number | null;
|
||||
user_name: string;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
|
||||
@@ -835,7 +835,7 @@ export default function Attendance() {
|
||||
onChange={(val) => handleSwitchProject(val || null)}
|
||||
disabled={switchingProject}
|
||||
options={[
|
||||
{ value: "", label: "— Bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1802,23 +1802,28 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
{isEdit && invoice && hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={pdfLoading}
|
||||
startIcon={
|
||||
pdfLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
FileIcon
|
||||
)
|
||||
}
|
||||
>
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
{/* Drafts have no number → /file 404s; hide the button like
|
||||
OfferDetail/IssuedOrderDetail do. */}
|
||||
{isEdit &&
|
||||
invoice &&
|
||||
invoice.invoice_number &&
|
||||
hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={pdfLoading}
|
||||
startIcon={
|
||||
pdfLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
FileIcon
|
||||
)
|
||||
}
|
||||
>
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
{/* ── Create mode: two-button save ── */}
|
||||
{!isEdit && (
|
||||
<>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
LoadingState,
|
||||
FilterBar,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
@@ -247,6 +248,9 @@ export default function Invoices() {
|
||||
} else {
|
||||
setStatsMonth((m) => m - 1);
|
||||
}
|
||||
// Back to page 1 — the server never clamps, so keeping e.g. page 3 in a
|
||||
// sparser month shows an empty table (same reset as the filter handlers).
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
@@ -257,6 +261,7 @@ export default function Invoices() {
|
||||
} else {
|
||||
setStatsMonth((m) => m + 1);
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
@@ -706,7 +711,7 @@ export default function Invoices() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{activeTab === "received" ? (
|
||||
<TabPanel value="received" current={activeTab} sx={{ pt: 0 }}>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<ReceivedInvoices
|
||||
statsMonth={statsMonth}
|
||||
@@ -715,7 +720,8 @@ export default function Invoices() {
|
||||
setUploadOpen={setReceivedUploadOpen}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
</TabPanel>
|
||||
<TabPanel value="issued" current={activeTab} sx={{ pt: 0 }}>
|
||||
<>
|
||||
{renderKpi()}
|
||||
|
||||
@@ -808,7 +814,7 @@ export default function Invoices() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export default function IssuedOrderDetail() {
|
||||
const editable = !readOnly;
|
||||
const canExport = hasPermission("orders.view");
|
||||
|
||||
const { markClean } = useUnsavedChangesGuard(
|
||||
const { isDirty, markClean } = useUnsavedChangesGuard(
|
||||
{ form, items, sections },
|
||||
!isEdit || dataReady,
|
||||
);
|
||||
@@ -506,11 +506,23 @@ export default function IssuedOrderDetail() {
|
||||
else void handleSubmit();
|
||||
};
|
||||
|
||||
// ─── Status transition (status-ONLY payload — a full-form resubmit on a
|
||||
// non-editable order now 400s server-side) ───
|
||||
// ─── Status transition. With unsaved edits on an editable (sent) order
|
||||
// the transition routes through handleSubmit so the edits are PERSISTED
|
||||
// with the status — a status-only payload would silently drop them, and
|
||||
// the markClean below would re-baseline the guard so even the beforeunload
|
||||
// warning disappears (the order is then read-only: edits unrecoverable).
|
||||
// Clean (or non-editable) orders keep the status-only payload — a
|
||||
// full-form resubmit on a non-editable order 400s server-side. ───
|
||||
const handleStatusChange = async () => {
|
||||
if (!statusConfirm.status || statusChanging) return;
|
||||
const newStatus = statusConfirm.status;
|
||||
if (editable && isDirty) {
|
||||
setStatusConfirm({ show: false, status: null });
|
||||
// handleSubmit validates, sends the full payload + status, archives
|
||||
// the PDF and re-baselines the guard with what was actually persisted.
|
||||
await handleSubmit(newStatus);
|
||||
return;
|
||||
}
|
||||
setStatusChanging(newStatus);
|
||||
try {
|
||||
const result = await statusMutation.mutateAsync({ status: newStatus });
|
||||
|
||||
@@ -133,6 +133,15 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
// Reset to page 1 when the parent moves to another month — the kept page
|
||||
// index would otherwise request e.g. page 2 of a sparser month and render
|
||||
// an empty list. Adjusted during render (adjust-state-on-prop-change
|
||||
// pattern); a remount via `key` would also clear search/filters.
|
||||
const [prevMonthYear, setPrevMonthYear] = useState(`${month}-${year}`);
|
||||
if (`${month}-${year}` !== prevMonthYear) {
|
||||
setPrevMonthYear(`${month}-${year}`);
|
||||
setPage(1);
|
||||
}
|
||||
// Track first successful load so later refetches (filter/status/page change)
|
||||
// keep the table visible instead of flashing the full-page skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
@@ -389,7 +389,17 @@ export default function OrderDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: the title must drop below the Zpět button on phones
|
||||
instead of overflowing the viewport (same as Offer/Invoice
|
||||
detail headers). */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/orders"
|
||||
@@ -399,9 +409,22 @@ export default function OrderDetail() {
|
||||
>
|
||||
Zpět
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Objednávka {order.order_number}
|
||||
Objednávka
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{order.order_number}
|
||||
</Box>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={statusLabel(ORDER_STATUS, order.status)}
|
||||
|
||||
@@ -5,7 +5,14 @@ import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { Button, Tabs, PageHeader, PageEnter, LoadingState } from "../ui";
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
LoadingState,
|
||||
} from "../ui";
|
||||
import OrdersReceived from "./ReceivedOrders";
|
||||
|
||||
const IssuedOrders = lazy(() => import("./IssuedOrders"));
|
||||
@@ -170,18 +177,19 @@ export default function Orders() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{activeTab === "vydane" ? (
|
||||
<TabPanel value="vydane" current={activeTab} sx={{ pt: 0 }}>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<IssuedOrders month={statsMonth} year={statsYear} />
|
||||
</Suspense>
|
||||
) : (
|
||||
</TabPanel>
|
||||
<TabPanel value="prijate" current={activeTab} sx={{ pt: 0 }}>
|
||||
<OrdersReceived
|
||||
month={statsMonth}
|
||||
year={statsYear}
|
||||
createOpen={createOpen}
|
||||
setCreateOpen={setCreateOpen}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import ProjectFileManager from "../components/ProjectFileManager";
|
||||
import NoteCard from "../components/NoteCard";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -49,17 +50,6 @@ const STATUS_COLORS: Record<
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
function formatNoteDate(dateStr: string) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -86,28 +76,12 @@ const BackIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
const { hasPermission, isAdmin } = useAuth();
|
||||
const { hasPermission, isAdmin, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<ProjectForm>({
|
||||
@@ -278,6 +252,30 @@ export default function ProjectDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// Throws on failure so NoteCard keeps its editor open with the draft.
|
||||
const saveNoteEdit = async (noteId: number, content: string) => {
|
||||
// Raw apiFetch: the body must carry ONLY content (strict schema), the
|
||||
// note id lives in the URL — useApiMutation sends its whole input.
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
throw new Error("network");
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
alert.error(body?.error || "Uložení selhalo");
|
||||
throw new Error(body?.error || "Uložení selhalo");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
alert.success("Poznámka upravena");
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
@@ -300,7 +298,17 @@ export default function ProjectDetail() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{/* flexWrap: the title must drop below the Zpět button on phones
|
||||
instead of being squeezed beside it (mirrors Offer/Invoice
|
||||
detail headers). */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/projects"
|
||||
@@ -310,9 +318,22 @@ export default function ProjectDetail() {
|
||||
>
|
||||
Zpět
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Projekt {project.project_number}
|
||||
Projekt
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{project.project_number}
|
||||
</Box>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={STATUS_LABELS[project.status] || project.status}
|
||||
@@ -493,61 +514,18 @@ export default function ProjectDetail() {
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{notes.map((note) => (
|
||||
<Box
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{note.user_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(note.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{note.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{TrashIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
authorName={note.user_name}
|
||||
createdAt={note.created_at}
|
||||
editedAt={note.edited_at}
|
||||
content={note.content || ""}
|
||||
canEdit={user?.id === note.user_id}
|
||||
canDelete={isAdmin}
|
||||
deleting={deletingNoteId === note.id}
|
||||
onSave={(content) => saveNoteEdit(note.id, content)}
|
||||
onDelete={() => handleDeleteNote(note.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -554,7 +554,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákaznika" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
@@ -571,7 +571,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, responsible_user_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— nepřiřazeno —" },
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...(usersQuery.data ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
type CompanySettingsData,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
StatCard,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
Pagination,
|
||||
type DataColumn,
|
||||
type StatCardColor,
|
||||
} from "../ui";
|
||||
@@ -262,14 +263,33 @@ export default function ReceivedInvoices({
|
||||
const { data: supplierNames = [] } = useQuery(supplierListOptions());
|
||||
const companySettings = useQuery(companySettingsOptions()).data;
|
||||
|
||||
// List query — auto-refetches when filters change
|
||||
const listQuery = useQuery(
|
||||
const [page, setPage] = useState(1);
|
||||
// Reset to page 1 when the parent moves to another month — the kept page
|
||||
// index would otherwise request e.g. page 2 of a sparser month and render
|
||||
// an empty list. Adjusted during render (adjust-state-on-prop-change
|
||||
// pattern); a remount via `key` would also clear the search field.
|
||||
const [prevMonthYear, setPrevMonthYear] = useState(
|
||||
`${statsMonth}-${statsYear}`,
|
||||
);
|
||||
if (`${statsMonth}-${statsYear}` !== prevMonthYear) {
|
||||
setPrevMonthYear(`${statsMonth}-${statsYear}`);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
// List query — auto-refetches when filters change. Paginated: the server
|
||||
// caps at 25/page, so without the page param rows 26+ were unreachable.
|
||||
const {
|
||||
items: invoices,
|
||||
pagination,
|
||||
isPending: listPending,
|
||||
} = usePaginatedQuery<ReceivedInvoice>(
|
||||
receivedInvoiceListOptions({
|
||||
month: statsMonth,
|
||||
year: statsYear,
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -290,13 +310,10 @@ export default function ReceivedInvoices({
|
||||
}),
|
||||
);
|
||||
|
||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||
const invoices = listQuery.data?.data ?? [];
|
||||
|
||||
// Track first successful load (used to suppress the skeleton on later
|
||||
// refetches). Set in an effect rather than during render to avoid a
|
||||
// render-phase ref mutation.
|
||||
const hasData = !!(listQuery.data || statsQuery.data);
|
||||
const hasData = !!(pagination || statsQuery.data);
|
||||
useEffect(() => {
|
||||
if (hasData) hasLoadedOnce.current = true;
|
||||
}, [hasData]);
|
||||
@@ -337,7 +354,7 @@ export default function ReceivedInvoices({
|
||||
},
|
||||
});
|
||||
|
||||
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
|
||||
const showListSkeleton = listPending && !hasLoadedOnce.current;
|
||||
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
|
||||
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
|
||||
@@ -810,73 +827,75 @@ export default function ReceivedInvoices({
|
||||
<>
|
||||
{renderKpi()}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<Card>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Hledat podle dodavatele nebo čísla faktury..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{showListSkeleton ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<ReceivedInvoice>
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(inv) => inv.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
<Card>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle dodavatele nebo čísla faktury..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{showListSkeleton ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<ReceivedInvoice>
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(inv) => inv.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal
|
||||
|
||||
@@ -625,7 +625,7 @@ export default function OrdersReceived({
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákazníka" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function Settings() {
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
const saveSystemSettingsMutation = useApiMutation<
|
||||
typeof sysForm,
|
||||
Partial<typeof sysForm>,
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: () => `${API_BASE}/company-settings`,
|
||||
@@ -424,7 +424,24 @@ export default function Settings() {
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
try {
|
||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||
// Send ONLY the fields this tab edits. sysForm also carries
|
||||
// numbering/currency fields captured once at init (sysFormInitialized
|
||||
// never resets), and the backend applies every defined field — sending
|
||||
// the whole form would silently revert a Firma-tab numbering edit made
|
||||
// in the meantime with stale values.
|
||||
await saveSystemSettingsMutation.mutateAsync({
|
||||
break_threshold_hours: sysForm.break_threshold_hours,
|
||||
break_duration_short: sysForm.break_duration_short,
|
||||
break_duration_long: sysForm.break_duration_long,
|
||||
clock_rounding_minutes: sysForm.clock_rounding_minutes,
|
||||
invoice_alert_email: sysForm.invoice_alert_email,
|
||||
leave_notify_email: sysForm.leave_notify_email,
|
||||
smtp_from: sysForm.smtp_from,
|
||||
smtp_from_name: sysForm.smtp_from_name,
|
||||
max_login_attempts: sysForm.max_login_attempts,
|
||||
lockout_minutes: sysForm.lockout_minutes,
|
||||
max_requests_per_minute: sysForm.max_requests_per_minute,
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createTheme, type Theme } from "@mui/material/styles";
|
||||
import {
|
||||
createTheme,
|
||||
type Theme,
|
||||
type PaletteColor,
|
||||
type PaletteColorChannel,
|
||||
} from "@mui/material/styles";
|
||||
|
||||
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||
@@ -33,6 +38,26 @@ export const theme = createTheme({
|
||||
warning: { main: "#b45309", contrastText: "#fff" },
|
||||
error: { main: "#b91c1c", contrastText: "#fff" },
|
||||
info: { main: "#1d4ed8", contrastText: "#fff" },
|
||||
// Sidebar section hues (full objects so cssVariables emits *Channel
|
||||
// tokens — the nav tiles use channel-alpha washes).
|
||||
teal: {
|
||||
main: "#0e8a7c",
|
||||
light: "#3aa99c",
|
||||
dark: "#0a675d",
|
||||
contrastText: "#fff",
|
||||
},
|
||||
violet: {
|
||||
main: "#6a4cb4",
|
||||
light: "#8a6fd0",
|
||||
dark: "#54399a",
|
||||
contrastText: "#fff",
|
||||
},
|
||||
slate: {
|
||||
main: "#5c6470",
|
||||
light: "#7d8694",
|
||||
dark: "#454c56",
|
||||
contrastText: "#fff",
|
||||
},
|
||||
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||
divider: "rgba(0,0,0,0.1)",
|
||||
@@ -46,6 +71,25 @@ export const theme = createTheme({
|
||||
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
|
||||
error: { main: "#ef4444", contrastText: "#1a1a1a" },
|
||||
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
|
||||
// Sidebar section hues — brighter mains so the glyphs read on dark.
|
||||
teal: {
|
||||
main: "#2dd4bf",
|
||||
light: "#5eead4",
|
||||
dark: "#14b8a6",
|
||||
contrastText: "#1a1a1a",
|
||||
},
|
||||
violet: {
|
||||
main: "#a78bfa",
|
||||
light: "#c4b5fd",
|
||||
dark: "#8b5cf6",
|
||||
contrastText: "#1a1a1a",
|
||||
},
|
||||
slate: {
|
||||
main: "#94a3b8",
|
||||
light: "#cbd5e1",
|
||||
dark: "#64748b",
|
||||
contrastText: "#1a1a1a",
|
||||
},
|
||||
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||
divider: "rgba(255,255,255,0.08)",
|
||||
@@ -239,6 +283,24 @@ export const fonts = {
|
||||
mono: FONT_MONO,
|
||||
};
|
||||
|
||||
// Custom sidebar-section palette entries (teal/violet/slate above). The
|
||||
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
|
||||
// the nav tiles build their alpha washes.
|
||||
type NavPaletteColor = PaletteColor & PaletteColorChannel;
|
||||
|
||||
declare module "@mui/material/styles" {
|
||||
interface Palette {
|
||||
teal: NavPaletteColor;
|
||||
violet: NavPaletteColor;
|
||||
slate: NavPaletteColor;
|
||||
}
|
||||
interface PaletteOptions {
|
||||
teal?: PaletteOptions["primary"];
|
||||
violet?: PaletteOptions["primary"];
|
||||
slate?: PaletteOptions["primary"];
|
||||
}
|
||||
}
|
||||
|
||||
export type IconBadgeColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
|
||||
@@ -88,9 +88,8 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.25,
|
||||
py: 0.6,
|
||||
py: 0.45,
|
||||
color: "text.secondary",
|
||||
"& svg": { width: 18, height: 18 },
|
||||
"&.Mui-selected, &.Mui-selected:hover": {
|
||||
bgcolor: "background.paper",
|
||||
color: "primary.main",
|
||||
@@ -99,8 +98,29 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
||||
{item.icon}
|
||||
<ListItemIcon sx={{ minWidth: 36, color: "inherit" }}>
|
||||
{item.rawIcon ? (
|
||||
item.icon
|
||||
) : (
|
||||
<Box
|
||||
sx={(t) => ({
|
||||
width: 26,
|
||||
height: 26,
|
||||
// Echoes OdinMark's rounded-square tile; only Odin
|
||||
// is solid + animated, these are quiet washes.
|
||||
borderRadius: "32%",
|
||||
flexShrink: 0,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: `rgba(${t.vars!.palette[section.hue].mainChannel} / 0.12)`,
|
||||
color: t.vars!.palette[section.hue].main,
|
||||
"& svg": { width: 16, height: 16 },
|
||||
})}
|
||||
>
|
||||
{item.icon}
|
||||
</Box>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Box from "@mui/material/Box";
|
||||
import { keyframes } from "@mui/system";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
|
||||
export interface TabDef {
|
||||
value: string;
|
||||
@@ -47,15 +49,26 @@ export function Tabs({
|
||||
);
|
||||
}
|
||||
|
||||
// The ONE tab-switch animation, app-wide: enter-only fade of the incoming
|
||||
// panel (M3 "clean fade" — old content drops instantly, no exit/slide; tabs
|
||||
// are too high-frequency for anything longer). Kept as a CSS animation so the
|
||||
// global prefers-reduced-motion kill switch in GlobalStyles neutralizes it.
|
||||
const panelEnter = keyframes({
|
||||
from: { opacity: 0, transform: "translateY(6px)" },
|
||||
to: { opacity: 1, transform: "none" },
|
||||
});
|
||||
|
||||
/** Renders children only when its value matches the current tab. */
|
||||
export function TabPanel({
|
||||
value,
|
||||
current,
|
||||
children,
|
||||
sx,
|
||||
}: {
|
||||
value: string;
|
||||
current: string;
|
||||
children: ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
}) {
|
||||
if (value !== current) return null;
|
||||
return (
|
||||
@@ -63,7 +76,13 @@ export function TabPanel({
|
||||
role="tabpanel"
|
||||
id={panelId(value)}
|
||||
aria-labelledby={tabId(value)}
|
||||
sx={{ pt: 2.5 }}
|
||||
sx={[
|
||||
{
|
||||
pt: 2.5,
|
||||
animation: `${panelEnter} 0.2s cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { type ReactNode } from "react";
|
||||
import OdinMark from "../components/odin/OdinMark";
|
||||
|
||||
/** Palette key driving a section's tile wash + glyph color (see theme.ts). */
|
||||
export type NavHue =
|
||||
| "primary"
|
||||
| "info"
|
||||
| "teal"
|
||||
| "warning"
|
||||
| "violet"
|
||||
| "slate";
|
||||
|
||||
export interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -10,33 +19,51 @@ export interface MenuItem {
|
||||
matchAlso?: string[];
|
||||
matchExclude?: string[];
|
||||
icon: ReactNode;
|
||||
/** Icon ships its own container (OdinMark) — SidebarNav skips the tile. */
|
||||
rawIcon?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
label: string;
|
||||
hue: NavHue;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One stroke glyph per item, every glyph unique within the set (pinned by
|
||||
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
|
||||
* 16px inside SidebarNav's 26px section-hue tile.
|
||||
*/
|
||||
function glyph(children: ReactNode) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
{
|
||||
label: "Přehled",
|
||||
hue: "primary",
|
||||
items: [
|
||||
{
|
||||
path: "/",
|
||||
label: "Přehled",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
// tachometr — dashboard
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="12" cy="13" r="8.5" />
|
||||
<path d="M12 13l4-4.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -44,78 +71,66 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Odin",
|
||||
permission: "ai.use",
|
||||
end: true,
|
||||
icon: <OdinMark size={22} />,
|
||||
icon: <OdinMark size={26} />,
|
||||
rawIcon: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Docházka",
|
||||
hue: "info",
|
||||
items: [
|
||||
{
|
||||
path: "/attendance",
|
||||
label: "Záznam",
|
||||
permission: "attendance.record",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
// píchačky — hodiny
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polyline points="12 7 12 12 15 15" />
|
||||
</svg>
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/attendance/history",
|
||||
label: "Moje historie",
|
||||
permission: "attendance.history",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="12 8 12 12 14 14" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</svg>
|
||||
// výkaz s hodinami — timesheet
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M14 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h6" />
|
||||
<line x1="7" y1="8" x2="13" y2="8" />
|
||||
<line x1="7" y1="12" x2="11" y2="12" />
|
||||
<circle cx="16.5" cy="16" r="4.5" />
|
||||
<polyline points="16.5 14 16.5 16 18 17" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/attendance/requests",
|
||||
label: "Žádosti",
|
||||
permission: "attendance.record",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
// odeslaná žádost — vlaštovka
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M22 2L11 13" />
|
||||
<path d="M22 2l-7 20-4-9-9-4z" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/attendance/approval",
|
||||
label: "Schvalování",
|
||||
permission: "attendance.approve",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
// razítko
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M9.5 12V9.8C9.5 8.4 8 7.8 8 6a4 4 0 0 1 8 0c0 1.8-1.5 2.4-1.5 3.8V12" />
|
||||
<path d="M5 16a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2H5z" />
|
||||
<line x1="5" y1="21" x2="19" y2="21" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -124,147 +139,108 @@ export const menuSections: MenuSection[] = [
|
||||
permission: "attendance.manage",
|
||||
matchPrefix: "/attendance/admin",
|
||||
matchAlso: ["/attendance/create", "/attendance/location"],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
// osoba + fajfka
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M3 20a6 6 0 0 1 12 0" />
|
||||
<path d="M15.5 11.5l2 2 4-4" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/attendance/balances",
|
||||
label: "Správa bilancí",
|
||||
permission: "attendance.balances",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
// kniha plus/mínus — ruční korekce bilancí
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="4" y="3" width="16" height="18" rx="2" />
|
||||
<line x1="9" y1="8.5" x2="15" y2="8.5" />
|
||||
<line x1="12" y1="5.5" x2="12" y2="11.5" />
|
||||
<line x1="9" y1="16" x2="15" y2="16" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/plan-work",
|
||||
label: "Plán prací",
|
||||
permission: ["attendance.manage", "attendance.record"],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
// kalendář s plánovacím blokem
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<line x1="3" y1="9.5" x2="21" y2="9.5" />
|
||||
<path d="M8 14h3v3H8z" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Kniha jízd",
|
||||
hue: "teal",
|
||||
items: [
|
||||
{
|
||||
path: "/trips",
|
||||
label: "Záznam",
|
||||
permission: "trips.record",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="5" cy="18" r="3" />
|
||||
<circle cx="19" cy="18" r="3" />
|
||||
<path d="M5 18V12L8 5h8l3 7v6" />
|
||||
<path d="M10 18h4" />
|
||||
</svg>
|
||||
// trasa A→B
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="5" cy="19" r="2" />
|
||||
<circle cx="19" cy="5" r="2" />
|
||||
<path d="M5 17C5 11 19 13 19 7" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/trips/history",
|
||||
label: "Moje historie",
|
||||
permission: "trips.history",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="12 8 12 12 14 14" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</svg>
|
||||
// navštívená místa — pin
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z" />
|
||||
<circle cx="12" cy="11" r="2.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/trips/admin",
|
||||
label: "Správa",
|
||||
permission: "trips.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
// schránka s fajfkou — kniha jízd
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" />
|
||||
<path d="M9 2h6v4H9z" />
|
||||
<path d="M9 13l2 2 4-4" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/vehicles",
|
||||
label: "Vozidla",
|
||||
permission: "vehicles.manage",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="1" y="3" width="15" height="13" rx="2" />
|
||||
<path d="M16 8h4l3 3v5h-7V8z" />
|
||||
<circle cx="5.5" cy="18.5" r="2.5" />
|
||||
<circle cx="18.5" cy="18.5" r="2.5" />
|
||||
</svg>
|
||||
// auto
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M5 17H3v-4l2-5h11l3 5h2v4h-2" />
|
||||
<circle cx="7.5" cy="17" r="2" />
|
||||
<circle cx="16.5" cy="17" r="2" />
|
||||
<line x1="9.5" y1="17" x2="14.5" y2="17" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Administrativa",
|
||||
hue: "warning",
|
||||
items: [
|
||||
{
|
||||
path: "/offers",
|
||||
@@ -272,16 +248,12 @@ export const menuSections: MenuSection[] = [
|
||||
permission: "offers.view",
|
||||
matchPrefix: "/offers",
|
||||
matchExclude: ["/offers/customers", "/offers/templates"],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
// cenovka
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M20.6 13.4L11 3H4v7l9.6 10.4a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 0 0-2.8z" />
|
||||
<circle cx="8" cy="8" r="1.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -289,17 +261,13 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Objednávky",
|
||||
permission: "orders.view",
|
||||
matchPrefix: "/orders",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
||||
</svg>
|
||||
// košík
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="9" cy="20" r="1.6" />
|
||||
<circle cx="17" cy="20" r="1.6" />
|
||||
<path d="M2 3h3l2.7 12h10.6l2.7-9H6" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -307,16 +275,13 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Faktury",
|
||||
permission: "invoices.view",
|
||||
matchPrefix: "/invoices",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
// účtenka
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M6 2h12v20l-3-2-3 2-3-2-3 2z" />
|
||||
<line x1="9" y1="7" x2="15" y2="7" />
|
||||
<line x1="9" y1="11" x2="15" y2="11" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -324,60 +289,44 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Projekty",
|
||||
permission: "projects.view",
|
||||
matchPrefix: "/projects",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
// složka (NAS projektové složky)
|
||||
icon: glyph(
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/offers/customers",
|
||||
label: "Zákazníci",
|
||||
permission: "customers.view",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
// vizitka
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<circle cx="8.5" cy="10.5" r="2.2" />
|
||||
<path d="M5 16.5a3.5 3.5 0 0 1 7 0" />
|
||||
<line x1="14.5" y1="9.5" x2="19" y2="9.5" />
|
||||
<line x1="14.5" y1="13.5" x2="19" y2="13.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sklad",
|
||||
hue: "violet",
|
||||
items: [
|
||||
{
|
||||
path: "/warehouse",
|
||||
label: "Přehled",
|
||||
permission: "warehouse.view",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
// skladová hala
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M3 21V9l9-5 9 5v12" />
|
||||
<path d="M7 21v-7h10v7" />
|
||||
<line x1="7" y1="17.5" x2="17" y2="17.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -385,17 +334,13 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Položky",
|
||||
permission: "warehouse.view",
|
||||
matchPrefix: "/warehouse/items",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
// krabice
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -403,17 +348,13 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Příjmy",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/receipts",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="17 11 12 6 7 11" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
// šipka do krabice
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="4" y="12" width="16" height="9" rx="1" />
|
||||
<polyline points="8 6 12 10 16 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="10" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -421,17 +362,13 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Výdeje",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/issues",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="7 13 12 18 17 13" />
|
||||
<line x1="12" y1="18" x2="12" y2="6" />
|
||||
<path d="M5 5h14" />
|
||||
</svg>
|
||||
// šipka z krabice ven
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="4" y="13" width="16" height="8" rx="1" />
|
||||
<polyline points="8 6 12 2 16 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="11" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -439,18 +376,12 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Rezervace",
|
||||
permission: "warehouse.operate",
|
||||
matchPrefix: "/warehouse/reservations",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="22" y1="11" x2="16" y2="11" />
|
||||
</svg>
|
||||
// krabice se záložkou
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="3" y="8" width="13" height="12" rx="1" />
|
||||
<path d="M16 3h5v9l-2.5-2L16 12z" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -458,16 +389,15 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Inventura",
|
||||
permission: "warehouse.inventory",
|
||||
matchPrefix: "/warehouse/inventory",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
// čárový kód — fyzický sken zásob
|
||||
icon: glyph(
|
||||
<>
|
||||
<line x1="4" y1="6" x2="4" y2="18" />
|
||||
<line x1="8" y1="6" x2="8" y2="18" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
<line x1="17" y1="6" x2="17" y2="18" />
|
||||
<line x1="20" y1="6" x2="20" y2="18" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -475,17 +405,12 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Reporty",
|
||||
permission: "warehouse.view",
|
||||
end: true,
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
// graf v rámu
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<polyline points="7 14 11 10 14 13 17 8" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -493,23 +418,14 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Kategorie",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/categories",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
// tvary — třídění do kategorií
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="7.5" cy="7.5" r="3.5" />
|
||||
<rect x="13.5" y="4" width="7" height="7" rx="1" />
|
||||
<path d="M7.5 13.5l3.5 7h-7z" />
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="3.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -517,18 +433,16 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Lokace",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/locations",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
// regál s krabicemi
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M3 21V4" />
|
||||
<path d="M21 21V4" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="21" x2="21" y2="21" />
|
||||
<rect x="7" y="8" width="4" height="4" />
|
||||
<rect x="13" y="17" width="4" height="4" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -536,41 +450,34 @@ export const menuSections: MenuSection[] = [
|
||||
label: "Dodavatelé",
|
||||
permission: "warehouse.manage",
|
||||
matchPrefix: "/warehouse/suppliers",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
// kamion
|
||||
icon: glyph(
|
||||
<>
|
||||
<rect x="1" y="6" width="13" height="10" rx="1" />
|
||||
<path d="M14 9h4l3 4v3h-7" />
|
||||
<circle cx="6" cy="18.5" r="2" />
|
||||
<circle cx="17.5" cy="18.5" r="2" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Systém",
|
||||
hue: "slate",
|
||||
items: [
|
||||
{
|
||||
path: "/users",
|
||||
label: "Uživatelé",
|
||||
permission: "users.view",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
// lidé — uživatelské účty
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -583,35 +490,27 @@ export const menuSections: MenuSection[] = [
|
||||
"settings.system",
|
||||
"settings.templates",
|
||||
],
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
// ozubené kolo
|
||||
icon: glyph(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/audit-log",
|
||||
label: "Audit log",
|
||||
permission: "settings.audit",
|
||||
icon: (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
// lupa nad záznamy
|
||||
icon: glyph(
|
||||
<>
|
||||
<path d="M4 5h12" />
|
||||
<path d="M4 10h6" />
|
||||
<path d="M4 15h5" />
|
||||
<circle cx="15" cy="14" r="4.5" />
|
||||
<line x1="18.2" y1="17.2" x2="21.5" y2="20.5" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
@@ -54,10 +54,14 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use)
|
||||
// PUT /api/admin/ai/budget — set the monthly budget. A COMPANY setting
|
||||
// (budget_usd=0 is a company-wide AI DoS; a huge value removes the cost
|
||||
// cap), so it is gated like company_settings writes, NOT by ai.use.
|
||||
app.put(
|
||||
"/budget",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
{
|
||||
preHandler: requireAnyPermission("settings.company", "settings.system"),
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(AiBudgetSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
|
||||
@@ -42,7 +42,10 @@ export default async function auditLogRoutes(
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) {
|
||||
const from = new Date(String(query.date_from));
|
||||
// LOCAL midnight, symmetric with the date_to side below — a bare
|
||||
// new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague) and
|
||||
// silently excluded events from the first morning hours.
|
||||
const from = new Date(String(query.date_from) + "T00:00:00");
|
||||
if (isNaN(from.getTime())) {
|
||||
return error(reply, "Neplatné datum od", 400);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ export default async function customersRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
// {id} tiebreak: the allow-listed sort columns are non-unique, and
|
||||
// offset pagination over a tied run reorders between page queries
|
||||
// (rows duplicate/vanish across pages) without a total order.
|
||||
orderBy: [{ [sortField]: order }, { id: order }],
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
|
||||
@@ -408,7 +408,22 @@ export default async function invoicesPdfRoutes(
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
// CNB may be unreachable (or the issue date never cached). The rate
|
||||
// only feeds the CZK recap + conversion footnote — degrade by
|
||||
// omitting them (cnbRate = null) rather than failing the whole
|
||||
// render AND the NAS archival (expected condition, logged).
|
||||
let cnbRate: number | null = 1.0;
|
||||
if (isForeign) {
|
||||
try {
|
||||
cnbRate = await getRate(currency, issueDateStr);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[invoices-pdf] Kurz ČNB nedostupný pro ${currency} — rekapitulace v Kč vynechána`,
|
||||
err,
|
||||
);
|
||||
cnbRate = null;
|
||||
}
|
||||
}
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
@@ -416,16 +431,18 @@ export default async function invoicesPdfRoutes(
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
if (cnbRate !== null) {
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
@@ -503,6 +520,39 @@ export default async function invoicesPdfRoutes(
|
||||
)
|
||||
.join("");
|
||||
|
||||
// CNB rate unavailable → the CZK figures cannot be computed; omit
|
||||
// the whole recap table (the payment QR next to it stays).
|
||||
const recapTableHtml =
|
||||
cnbRate === null
|
||||
? ""
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">${escapeHtml(t.vat_recap)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${escapeHtml(t.vat_base)}</th>
|
||||
<th>${escapeHtml(t.vat_rate)}</th>
|
||||
<th>${escapeHtml(t.vat_amount)}</th>
|
||||
<th>${escapeHtml(t.vat_with_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${vatRecapHtml}
|
||||
</tbody>
|
||||
${
|
||||
isForeign
|
||||
? `<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||||
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>`
|
||||
: ""
|
||||
}
|
||||
</table>`;
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
@@ -1050,33 +1100,7 @@ ${indentCSS}
|
||||
<div class="qr">
|
||||
${qrSvg}
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">${escapeHtml(t.vat_recap)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${escapeHtml(t.vat_base)}</th>
|
||||
<th>${escapeHtml(t.vat_rate)}</th>
|
||||
<th>${escapeHtml(t.vat_amount)}</th>
|
||||
<th>${escapeHtml(t.vat_with_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${vatRecapHtml}
|
||||
</tbody>
|
||||
${
|
||||
isForeign
|
||||
? `<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||||
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>`
|
||||
: ""
|
||||
}
|
||||
</table>
|
||||
${recapTableHtml}
|
||||
</div>
|
||||
|
||||
<!-- Prevzal / razitko -->
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ReviewLeaveRequestSchema,
|
||||
} from "../../schemas/leave-requests.schema";
|
||||
import { notifyNewLeaveRequest } from "../../services/leave-notification";
|
||||
import { isHoliday } from "../../utils/czech-holidays";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
|
||||
const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const;
|
||||
const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const;
|
||||
@@ -87,12 +89,16 @@ export default async function leaveRequestsRoutes(
|
||||
return error(reply, "Datum do musí být po datu od", 400);
|
||||
}
|
||||
|
||||
// Compute business days server-side (matching PHP logic)
|
||||
// Compute business days server-side — weekends AND Czech public
|
||||
// holidays excluded (mirrors attendance.service createLeave: a weekday
|
||||
// holiday is already paid/free, charging it would double-deduct).
|
||||
let businessDays = 0;
|
||||
const current = new Date(dateFrom);
|
||||
while (current <= dateTo) {
|
||||
const day = current.getDay();
|
||||
if (day !== 0 && day !== 6) businessDays++;
|
||||
if (day !== 0 && day !== 6 && !isHoliday(localDateStr(current))) {
|
||||
businessDays++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -185,31 +191,19 @@ export default async function leaveRequestsRoutes(
|
||||
}
|
||||
|
||||
if (status === "approved") {
|
||||
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
|
||||
// --- APPROVAL: create attendance records + update leave balance ---
|
||||
const leaveType = existing.leave_type as string;
|
||||
const dateFrom = new Date(existing.date_from);
|
||||
const dateTo = new Date(existing.date_to);
|
||||
|
||||
// For vacation: re-check balance at approval time
|
||||
if (leaveType === "vacation") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
const totalHours = Number(existing.total_hours) || 0;
|
||||
if (totalHours > vacRemaining) {
|
||||
return error(
|
||||
reply,
|
||||
`Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the range once (mirrors attendance.service createLeave):
|
||||
// weekends AND Czech public holidays are skipped — a weekday holiday
|
||||
// is already paid/free, charging it would double-deduct — and hours
|
||||
// accumulate per CALENDAR YEAR so a Dec→Jan request books each day
|
||||
// against its own year's balance. The stored total_hours is NOT
|
||||
// trusted (pre-fix requests carry holiday-inflated totals).
|
||||
let totalBusinessDays = 0;
|
||||
const hoursByYear = new Map<number, number>();
|
||||
const current = new Date(dateFrom);
|
||||
const attendanceCreates: Array<{
|
||||
user_id: number;
|
||||
@@ -221,8 +215,10 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
while (current <= dateTo) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
|
||||
totalBusinessDays++;
|
||||
const dayYear = current.getFullYear();
|
||||
hoursByYear.set(dayYear, (hoursByYear.get(dayYear) ?? 0) + 8);
|
||||
attendanceCreates.push({
|
||||
user_id: existing.user_id,
|
||||
shift_date: new Date(
|
||||
@@ -245,6 +241,26 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
// For vacation: re-check the balance at approval time — per year,
|
||||
// each against ITS OWN remaining entitlement.
|
||||
if (leaveType === "vacation") {
|
||||
for (const [year, yearHours] of hoursByYear) {
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
if (yearHours > vacRemaining) {
|
||||
return error(
|
||||
reply,
|
||||
`Nedostatek dovolené pro rok ${year}. Zbývá ${vacRemaining}h, požadováno ${yearHours}h.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Check for duplicate attendance records inside the transaction
|
||||
@@ -264,38 +280,40 @@ export default async function leaveRequestsRoutes(
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
// 2. Update leave balances (vacation/sick only — not unpaid),
|
||||
// each calendar year charged separately.
|
||||
if (leaveType === "vacation" || leaveType === "sick") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
for (const [year, yearHours] of hoursByYear) {
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + totalHours;
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + yearHours;
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + yearHours;
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + totalHours;
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? yearHours : 0,
|
||||
sick_used: leaveType === "sick" ? yearHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateProjectSchema,
|
||||
CreateProjectSchema,
|
||||
CreateProjectNoteSchema,
|
||||
UpdateProjectNoteSchema,
|
||||
} from "../../schemas/projects.schema";
|
||||
import {
|
||||
listProjects,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteProject,
|
||||
createProjectNote,
|
||||
deleteProjectNote,
|
||||
updateProjectNote,
|
||||
} from "../../services/projects.service";
|
||||
|
||||
// Body for DELETE /:id — optional delete_files flag. The body may be absent on
|
||||
@@ -174,7 +176,47 @@ export default async function projectsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId
|
||||
// PUT /api/admin/projects/:id/notes/:noteId — author-only edit; the edit
|
||||
// is visibly stamped (edited_at) so a changed note can't pose as original.
|
||||
fastify.put<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
async (request, reply) => {
|
||||
const noteId = parseId(request.params.noteId, reply);
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
const parsed = parseBody(UpdateProjectNoteSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const authData = request.authData!;
|
||||
|
||||
const result = await updateProjectNote(
|
||||
projectId,
|
||||
noteId,
|
||||
authData.userId,
|
||||
parsed.data.content,
|
||||
);
|
||||
if (!result) return error(reply, "Poznámka nenalezena", 404);
|
||||
if (result.error !== undefined) {
|
||||
return error(reply, result.error, result.status ?? 403);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
description: `Upravena poznámka projektu`,
|
||||
oldValues: { content: result.previousContent },
|
||||
newValues: { content: result.note.content },
|
||||
});
|
||||
return success(reply, { note: result.note }, 200, "Poznámka upravena");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId — admin only (user
|
||||
// decision: authors edit their notes, only an admin removes anyone's).
|
||||
fastify.delete<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
@@ -183,6 +225,9 @@ export default async function projectsRoutes(
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
if (request.authData!.roleName !== "admin") {
|
||||
return error(reply, "Poznámky může mazat pouze administrátor", 403);
|
||||
}
|
||||
|
||||
const note = await deleteProjectNote(projectId, noteId);
|
||||
if (!note) return error(reply, "Poznámka nenalezena", 404);
|
||||
|
||||
@@ -141,7 +141,9 @@ export default async function receivedInvoicesRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
// {id} tiebreak — non-unique sort keys reorder between page
|
||||
// queries without it (rows duplicate/vanish across pages).
|
||||
orderBy: [{ [sortField]: order }, { id: order }],
|
||||
}),
|
||||
prisma.received_invoices.count({ where }),
|
||||
]);
|
||||
@@ -396,6 +398,19 @@ export default async function receivedInvoicesRoutes(
|
||||
const issueDate = meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null;
|
||||
const dueDate = meta.due_date
|
||||
? new Date(String(meta.due_date))
|
||||
: null;
|
||||
// The schema regex can't catch impossible dates ("2098-99-99") —
|
||||
// an Invalid Date here would derive NaN month/year and fail at
|
||||
// Prisma only AFTER the NAS save (orphaned file). Reject before
|
||||
// any side effect.
|
||||
if (issueDate && isNaN(issueDate.getTime())) {
|
||||
return error(reply, "Neplatné datum vystavení", 400);
|
||||
}
|
||||
if (dueDate && isNaN(dueDate.getTime())) {
|
||||
return error(reply, "Neplatné datum splatnosti", 400);
|
||||
}
|
||||
const invoiceMonth = issueDate
|
||||
? issueDate.getMonth() + 1
|
||||
: Number(meta.month) || now.getMonth() + 1;
|
||||
@@ -432,10 +447,8 @@ export default async function receivedInvoicesRoutes(
|
||||
currency: meta.currency ? String(meta.currency) : "CZK",
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
issue_date: meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null,
|
||||
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
|
||||
issue_date: issueDate,
|
||||
due_date: dueDate,
|
||||
status: "unpaid",
|
||||
notes: meta.notes ? String(meta.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
|
||||
@@ -60,6 +60,11 @@ export default async function usersRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
// Privilege-escalation guard (mirrors PUT): role assignment is
|
||||
// admin-only. A bare users.create holder could otherwise mint an
|
||||
// account on a high-privilege role the caller itself lacks.
|
||||
const callerIsAdmin = request.authData?.roleName === "admin";
|
||||
|
||||
const result = await createUser(
|
||||
{
|
||||
username: body.username,
|
||||
@@ -67,7 +72,7 @@ export default async function usersRoutes(
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
role_id: callerIsAdmin ? body.role_id : undefined,
|
||||
is_active: body.is_active,
|
||||
},
|
||||
request.authData?.roleName ?? undefined,
|
||||
|
||||
@@ -73,6 +73,90 @@ function numFilter(raw: unknown): number | undefined {
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-day boundaries for created_at range filters. created_at holds
|
||||
* LOCAL-time instants, so the from-bound is the day's local midnight — a
|
||||
* bare new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague), which
|
||||
* silently dropped the first morning hours. The to-bound is the NEXT day's
|
||||
* local midnight, exclusive — `lte` against UTC midnight dropped the entire
|
||||
* inclusive end day.
|
||||
*/
|
||||
function localDayStart(dateStr: string): Date {
|
||||
return new Date(`${dateStr}T00:00:00`);
|
||||
}
|
||||
function localDayEndExclusive(dateStr: string): Date {
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* FK pre-validation for receipt/issue payloads (mirrors the document
|
||||
* modules): a dangling id would otherwise raise P2003 at Prisma and surface
|
||||
* as a generic 500. Returns a Czech message for a clean 400, or null when
|
||||
* all referenced rows exist.
|
||||
*/
|
||||
async function findMissingLineRef(
|
||||
items: Array<{ item_id: number; location_id?: number | null }>,
|
||||
): Promise<string | null> {
|
||||
for (const line of items) {
|
||||
const item = await prisma.sklad_items.findUnique({
|
||||
where: { id: line.item_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!item) return `Položka ID ${line.item_id} nenalezena`;
|
||||
if (line.location_id != null) {
|
||||
const location = await prisma.sklad_locations.findUnique({
|
||||
where: { id: line.location_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!location) return `Lokace ID ${line.location_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findMissingReceiptRef(body: {
|
||||
supplier_id?: number | null;
|
||||
items?: Array<{ item_id: number; location_id?: number | null }>;
|
||||
}): Promise<string | null> {
|
||||
if (body.supplier_id != null) {
|
||||
const supplier = await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: body.supplier_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!supplier) return "Dodavatel nenalezen";
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
async function findMissingIssueRef(body: {
|
||||
project_id?: number;
|
||||
items?: Array<{
|
||||
item_id: number;
|
||||
batch_id?: number | null;
|
||||
location_id?: number | null;
|
||||
}>;
|
||||
}): Promise<string | null> {
|
||||
if (body.project_id != null) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: body.project_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) return "Projekt nenalezen";
|
||||
}
|
||||
for (const line of body.items ?? []) {
|
||||
if (line.batch_id != null) {
|
||||
const batch = await prisma.sklad_batches.findUnique({
|
||||
where: { id: line.batch_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!batch) return `Šarže ID ${line.batch_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
// NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it
|
||||
// per sub-entity (items/receipts/issues/reservations/inventory/reports) is a
|
||||
// tracked refactor, deferred — see REVIEW_FINDINGS.md.
|
||||
@@ -625,13 +709,22 @@ export default async function warehouseRoutes(
|
||||
"/items",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
async (request, reply) => {
|
||||
const { page, limit, skip, search } = parsePagination(
|
||||
const { page, limit, skip, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const categoryFilter = query.category_id
|
||||
? Number(query.category_id)
|
||||
: undefined;
|
||||
// Allow-listed DB sort columns (computed columns like total_quantity
|
||||
// can't be ordered server-side); anything else falls back to name.
|
||||
const ITEM_SORT_FIELDS = ["name", "item_number", "created_at"];
|
||||
const sortField = ITEM_SORT_FIELDS.includes(String(query.sort))
|
||||
? String(query.sort)
|
||||
: "name";
|
||||
// Default stays ASC (the route always sorted name asc); parsePagination
|
||||
// defaults to desc, so only honor it when the client sent one.
|
||||
const sortOrder = query.order ? order : "asc";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (search || categoryFilter !== undefined) {
|
||||
@@ -659,7 +752,9 @@ export default async function warehouseRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { name: "asc" },
|
||||
// {id} tiebreak — name/item_number are non-unique; offset
|
||||
// pagination needs a total order or rows duplicate/vanish.
|
||||
orderBy: [{ [sortField]: sortOrder }, { id: sortOrder }],
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
item_locations: {
|
||||
@@ -1032,10 +1127,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ supplier_id: supplierId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1114,6 +1213,9 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: {
|
||||
supplier_id: body.supplier_id ?? null,
|
||||
@@ -1183,6 +1285,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.supplier_id !== undefined)
|
||||
updateData.supplier_id = body.supplier_id;
|
||||
@@ -1498,10 +1603,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ project_id: projectId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1585,6 +1694,11 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
// Before FIFO resolution, so a nonexistent item gets "Položka
|
||||
// nenalezena" instead of a misleading insufficient-stock message.
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
// Resolve FIFO batches for items without batch_id
|
||||
const resolvedItems: Array<{
|
||||
item_id: number;
|
||||
@@ -1704,6 +1818,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.project_id !== undefined)
|
||||
updateData.project_id = body.project_id;
|
||||
@@ -2400,12 +2517,12 @@ export default async function warehouseRoutes(
|
||||
}
|
||||
if (query.date_from) {
|
||||
issueWhere.push({
|
||||
created_at: { gte: new Date(String(query.date_from)) },
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
issueWhere.push({
|
||||
created_at: { lte: new Date(String(query.date_to)) },
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2491,14 +2608,14 @@ export default async function warehouseRoutes(
|
||||
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
|
||||
|
||||
if (query.date_from) {
|
||||
const dateFrom = new Date(String(query.date_from));
|
||||
const dateFrom = localDayStart(String(query.date_from));
|
||||
receiptWhere.push({ created_at: { gte: dateFrom } });
|
||||
issueWhere.push({ created_at: { gte: dateFrom } });
|
||||
}
|
||||
if (query.date_to) {
|
||||
const dateTo = new Date(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lte: dateTo } });
|
||||
issueWhere.push({ created_at: { lte: dateTo } });
|
||||
const dateTo = localDayEndExclusive(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lt: dateTo } });
|
||||
issueWhere.push({ created_at: { lt: dateTo } });
|
||||
}
|
||||
|
||||
const [receipts, issues] = await Promise.all([
|
||||
|
||||
@@ -27,7 +27,10 @@ export const TotpBackupSchema = z.object({
|
||||
});
|
||||
|
||||
export const TotpEnableSchema = z.object({
|
||||
secret: z.string().min(1).max(255),
|
||||
// The secret is stored AES-256-GCM encrypted (base64 of IV+ciphertext+tag)
|
||||
// in a VarChar(255) column — plaintext over ~164 chars overflows AFTER
|
||||
// encryption. Real TOTP secrets are 16-32 chars; 64 is generous.
|
||||
secret: z.string().min(1).max(64),
|
||||
code: z.string().min(1).max(64),
|
||||
password: z.string().max(200).optional(),
|
||||
current_code: z.string().max(64).optional(),
|
||||
|
||||
@@ -110,6 +110,26 @@ export const isoDateString = z.preprocess(
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nullable/optional variant of isoDateString for edit-form date fields:
|
||||
* "" and null normalize to null (the forms clear date inputs with empty
|
||||
* strings), undefined stays undefined (update semantics: field untouched).
|
||||
* Same lenient trailing-time stripping — a local-midnight datetime written
|
||||
* to a @db.Date column would land a day early (Prisma truncates to the UTC
|
||||
* date part).
|
||||
*/
|
||||
export const nullableIsoDateString = z.preprocess(
|
||||
(v) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === "") return null;
|
||||
return typeof v === "string" ? v.slice(0, 10) : v;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD")
|
||||
.nullish(),
|
||||
);
|
||||
|
||||
/** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */
|
||||
export const timeString = z.preprocess(
|
||||
(v) =>
|
||||
|
||||
@@ -4,10 +4,10 @@ export const CreateCustomerSchema = z.object({
|
||||
name: z.string().min(1, "Název zákazníka je povinný").max(255),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(255).nullish(),
|
||||
country: z.string().max(255).nullish(),
|
||||
company_id: z.string().max(255).nullish(),
|
||||
vat_id: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
country: z.string().max(100).nullish(),
|
||||
company_id: z.string().max(50).nullish(),
|
||||
vat_id: z.string().max(50).nullish(),
|
||||
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||
customer_field_order: z.array(z.string()).optional(),
|
||||
});
|
||||
@@ -16,10 +16,10 @@ export const UpdateCustomerSchema = z.object({
|
||||
name: z.string().min(1, "Název zákazníka je povinný").max(255).optional(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(255).nullish(),
|
||||
country: z.string().max(255).nullish(),
|
||||
company_id: z.string().max(255).nullish(),
|
||||
vat_id: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
country: z.string().max(100).nullish(),
|
||||
company_id: z.string().max(50).nullish(),
|
||||
vat_id: z.string().max(50).nullish(),
|
||||
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||
customer_field_order: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -6,39 +6,40 @@ import {
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
nullableIsoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
const InvoiceItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
|
||||
export const CreateInvoiceSchema = z.object({
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
invoice_number: z.string().max(50).nullish(),
|
||||
order_id: nullableIntIdFromForm.nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
status: z.string().max(20).optional().default("draft"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
currency: z.string().max(10).optional().default("CZK"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
payment_method: z.string().max(255).nullish(),
|
||||
constant_symbol: z.string().max(255).nullish(),
|
||||
payment_method: z.string().max(50).nullish(),
|
||||
constant_symbol: z.string().max(20).nullish(),
|
||||
bank_name: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(255).nullish(),
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
issue_date: z.string().max(255).nullish(),
|
||||
due_date: z.string().max(255).nullish(),
|
||||
tax_date: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(20).nullish(),
|
||||
bank_iban: z.string().max(50).nullish(),
|
||||
bank_account: z.string().max(50).nullish(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
tax_date: nullableIsoDateString,
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
billing_text: z.string().max(500).nullish(),
|
||||
language: z.string().max(5).optional().default("cs"),
|
||||
// `notes` was dropped (printed-notes block removed — free-form PDF content
|
||||
// lives in the sections); legacy clients still sending it are silently
|
||||
// stripped by z.object. internal_notes is never printed.
|
||||
@@ -51,24 +52,24 @@ export const CreateInvoiceSchema = z.object({
|
||||
|
||||
export const UpdateInvoiceSchema = z.object({
|
||||
status: z.string().max(20).optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
payment_method: z.string().max(255).nullish(),
|
||||
constant_symbol: z.string().max(255).nullish(),
|
||||
currency: z.string().max(10).optional(),
|
||||
language: z.string().max(5).optional(),
|
||||
payment_method: z.string().max(50).nullish(),
|
||||
constant_symbol: z.string().max(20).nullish(),
|
||||
bank_name: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(255).nullish(),
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(20).nullish(),
|
||||
bank_iban: z.string().max(50).nullish(),
|
||||
bank_account: z.string().max(50).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
billing_text: z.string().max(500).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
tax_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
tax_date: nullableIsoDateString,
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
paid_date: nullableIsoDateString,
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
} from "./common";
|
||||
|
||||
const OrderItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
@@ -27,20 +27,20 @@ const OrderSectionSchema = z.object({
|
||||
|
||||
export const CreateOrderFromQuotationSchema = z.object({
|
||||
quotationId: intIdFromForm,
|
||||
customerOrderNumber: z.string().max(255).optional().default(""),
|
||||
customerOrderNumber: z.string().max(100).optional().default(""),
|
||||
});
|
||||
|
||||
export const CreateOrderSchema = z.object({
|
||||
order_number: z.string().max(255).nullish(),
|
||||
customer_order_number: z.string().max(255).nullish(),
|
||||
order_number: z.string().max(50).nullish(),
|
||||
customer_order_number: z.string().max(100).nullish(),
|
||||
quotation_id: nullableIntIdFromForm.nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
status: z
|
||||
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
|
||||
.optional()
|
||||
.default("prijata"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
currency: z.string().max(10).optional().default("CZK"),
|
||||
language: z.string().max(5).optional().default("cs"),
|
||||
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -51,10 +51,10 @@ export const CreateOrderSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateOrderSchema = z.object({
|
||||
customer_order_number: z.string().max(255).nullish(),
|
||||
customer_order_number: z.string().max(100).nullish(),
|
||||
status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
currency: z.string().max(10).optional(),
|
||||
language: z.string().max(5).optional(),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
|
||||
@@ -27,6 +27,14 @@ export const CreateProjectNoteSchema = z.object({
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const UpdateProjectNoteSchema = z.strictObject({
|
||||
content: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Poznámka nesmí být prázdná")
|
||||
.max(5000, "Poznámka může mít maximálně 5000 znaků"),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { numberInRange, nonNegativeNumberFromForm } from "./common";
|
||||
import {
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
nullableIsoDateString,
|
||||
} from "./common";
|
||||
|
||||
export const CreateReceivedInvoiceSchema = z.object({
|
||||
supplier_name: z.string().min(1, "Název dodavatele je povinný").max(255),
|
||||
month: numberInRange(1, 12),
|
||||
year: numberInRange(2000, 2100),
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
description: z.string().max(8000).nullish(),
|
||||
invoice_number: z.string().max(100).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
amount: nonNegativeNumberFromForm.optional().default(0),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
currency: z.string().max(3).optional().default("CZK"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21),
|
||||
vat_amount: nonNegativeNumberFromForm.optional().default(0),
|
||||
issue_date: z.string().max(255).nullish(),
|
||||
due_date: z.string().max(255).nullish(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
status: z.enum(["unpaid", "paid"]).optional().default("unpaid"),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
});
|
||||
|
||||
export const UpdateReceivedInvoiceSchema = z.object({
|
||||
supplier_name: z.string().max(255).optional(),
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
description: z.string().max(8000).nullish(),
|
||||
invoice_number: z.string().max(100).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
amount: nonNegativeNumberFromForm.optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
currency: z.string().max(3).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
vat_amount: nonNegativeNumberFromForm.optional(),
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
paid_date: nullableIsoDateString,
|
||||
status: z.enum(["unpaid", "paid"]).optional(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
month: numberInRange(1, 12).optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
intIdFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
nonNegativeIntFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
} from "./common";
|
||||
@@ -12,10 +12,12 @@ export const CreateTripSchema = z.object({
|
||||
// when the client doesn't provide it (see POST /trips handler)
|
||||
user_id: intIdFromForm.optional(),
|
||||
trip_date: isoDateString,
|
||||
start_km: nonNegativeNumberFromForm,
|
||||
end_km: nonNegativeNumberFromForm,
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(255),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(255),
|
||||
// Int columns — a decimal odometer reading would 500 at Prisma (or
|
||||
// truncate, corrupting the derived distance and vehicles.actual_km).
|
||||
start_km: nonNegativeIntFromForm,
|
||||
end_km: nonNegativeIntFromForm,
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(100),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(100),
|
||||
is_business: booleanFromForm.optional().default(false),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
});
|
||||
@@ -24,10 +26,10 @@ 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(),
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(255).optional(),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(255).optional(),
|
||||
start_km: nonNegativeIntFromForm.optional(),
|
||||
end_km: nonNegativeIntFromForm.optional(),
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(100).optional(),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(100).optional(),
|
||||
is_business: booleanFromForm.optional(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { nonNegativeNumberFromForm } from "./common";
|
||||
import { nonNegativeIntFromForm } from "./common";
|
||||
|
||||
export const CreateVehicleSchema = z.object({
|
||||
spz: z.string().min(1, "SPZ je povinná").max(50),
|
||||
name: z.string().min(1, "Název je povinný").max(255),
|
||||
brand: z.string().nullish(),
|
||||
model: z.string().nullish(),
|
||||
initial_km: nonNegativeNumberFromForm.optional().default(0),
|
||||
actual_km: nonNegativeNumberFromForm.optional().default(0),
|
||||
// Int columns — decimals would 500 at Prisma or silently truncate.
|
||||
initial_km: nonNegativeIntFromForm.optional().default(0),
|
||||
actual_km: nonNegativeIntFromForm.optional().default(0),
|
||||
is_active: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
.optional()
|
||||
@@ -19,8 +20,8 @@ export const UpdateVehicleSchema = z.object({
|
||||
name: z.string().max(255).optional(),
|
||||
brand: z.string().nullish(),
|
||||
model: z.string().nullish(),
|
||||
initial_km: nonNegativeNumberFromForm.optional(),
|
||||
actual_km: nonNegativeNumberFromForm.optional(),
|
||||
initial_km: nonNegativeIntFromForm.optional(),
|
||||
actual_km: nonNegativeIntFromForm.optional(),
|
||||
is_active: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
.optional(),
|
||||
|
||||
@@ -31,8 +31,8 @@ export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
|
||||
// stripped by z.object). Limits are DB-aligned.
|
||||
export const CreateSupplierSchema = z.object({
|
||||
name: z.string().min(1, "Název je povinný").max(255),
|
||||
ico: z.string().max(255).nullish(),
|
||||
dic: z.string().max(255).nullish(),
|
||||
ico: z.string().max(20).nullish(),
|
||||
dic: z.string().max(20).nullish(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
@@ -42,8 +42,8 @@ export const CreateSupplierSchema = z.object({
|
||||
});
|
||||
export const UpdateSupplierSchema = z.object({
|
||||
name: z.string().min(1, "Název je povinný").max(255).optional(),
|
||||
ico: z.string().max(255).nullish(),
|
||||
dic: z.string().max(255).nullish(),
|
||||
ico: z.string().max(20).nullish(),
|
||||
dic: z.string().max(20).nullish(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getWorkfund,
|
||||
getProjectReport,
|
||||
} from "./attendance.service";
|
||||
import { toCzk } from "./exchange-rates";
|
||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||
|
||||
/**
|
||||
@@ -1319,6 +1320,7 @@ const TOOLS: ToolSpec[] = [
|
||||
recent_notes: p.project_notes.slice(0, 5).map((n) => ({
|
||||
author: n.user_name,
|
||||
date: n.created_at,
|
||||
...(n.edited_at ? { edited_at: n.edited_at } : {}),
|
||||
text: stripHtml(n.content).slice(0, 300),
|
||||
})),
|
||||
};
|
||||
@@ -1688,35 +1690,38 @@ const TOOLS: ToolSpec[] = [
|
||||
},
|
||||
{
|
||||
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||
// document type (offers vs orders families).
|
||||
permission: ["offers.view", "orders.view"],
|
||||
// document type (offers vs orders vs invoices families).
|
||||
permission: ["offers.view", "orders.view", "invoices.view"],
|
||||
definition: {
|
||||
name: "get_document_totals",
|
||||
description:
|
||||
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách, BEZ DPH. Typy: offers (nabídky), orders (objednávky přijaté), issued_orders (objednávky vydané). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů, když je výsledků víc než 20.",
|
||||
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách. Typy: offers (nabídky, bez DPH), orders (objednávky přijaté, bez DPH), issued_orders (objednávky vydané, bez DPH), received_invoices (přijaté faktury = VÝDAJE, včetně DPH, s přepočtem do CZK). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů. Na dotazy 'kolik jsme utratili' použij received_invoices a/nebo issued_orders.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["offers", "orders", "issued_orders"],
|
||||
description: "Typ dokumentů",
|
||||
enum: ["offers", "orders", "issued_orders", "received_invoices"],
|
||||
description:
|
||||
"Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
description: "Filtr stavu (vynech pro všechny)",
|
||||
description:
|
||||
"Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)",
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Hledání jako v list_* nástroji",
|
||||
description:
|
||||
"Hledání jako v list_* nástroji (ne received_invoices)",
|
||||
},
|
||||
month: {
|
||||
type: "number",
|
||||
description: "Měsíc 1-12 (jen orders/issued_orders)",
|
||||
description: "Měsíc 1-12 (ne offers)",
|
||||
},
|
||||
year: {
|
||||
type: "number",
|
||||
description: "Rok (jen orders/issued_orders)",
|
||||
description: "Rok (ne offers)",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
@@ -1766,6 +1771,59 @@ const TOOLS: ToolSpec[] = [
|
||||
const { totals } = await getOrderTotals(filters);
|
||||
return { type, period, totals, note: "Součty bez DPH, po měnách." };
|
||||
}
|
||||
if (type === "received_invoices") {
|
||||
if (!ctxCan(ctx, "invoices.view")) {
|
||||
return { error: DENIED("přijaté faktury") };
|
||||
}
|
||||
// month/year are literal columns on received_invoices; gross sums
|
||||
// per currency + CZK conversion mirror GET /received-invoices/stats.
|
||||
const where: Record<string, unknown> = {};
|
||||
if (month && year) {
|
||||
where.month = month;
|
||||
where.year = year;
|
||||
} else if (year) {
|
||||
where.year = year;
|
||||
}
|
||||
const status = str(input.status);
|
||||
if (status === "unpaid" || status === "paid") where.status = status;
|
||||
const rows = await prisma.received_invoices.findMany({
|
||||
where,
|
||||
select: { amount: true, currency: true },
|
||||
});
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const cur = r.currency || "CZK";
|
||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(r.amount) || 0);
|
||||
}
|
||||
const totals = Object.entries(byCurrency).map(([currency, amount]) => ({
|
||||
currency,
|
||||
amount: round2(amount),
|
||||
}));
|
||||
// CZK conversion mirrors the stats route: a row with an unknown
|
||||
// currency is skipped (logged), never fails the whole sum.
|
||||
let totalCzk = 0;
|
||||
let skipped = 0;
|
||||
for (const r of rows) {
|
||||
try {
|
||||
totalCzk += await toCzk(Number(r.amount) || 0, r.currency);
|
||||
} catch (e) {
|
||||
skipped += 1;
|
||||
console.error(
|
||||
`[ai-tools] toCzk failed for currency "${r.currency}"`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type,
|
||||
period: month && year ? `${month}/${year}` : year ? `${year}` : "vše",
|
||||
invoice_count: rows.length,
|
||||
totals,
|
||||
total_czk: round2(totalCzk),
|
||||
...(skipped ? { czk_conversion_skipped_rows: skipped } : {}),
|
||||
note: "Částky jsou VČETNĚ DPH (gross); total_czk je přepočet aktuálním kurzem.",
|
||||
};
|
||||
}
|
||||
if (type === "issued_orders") {
|
||||
if (!ctxCan(ctx, "orders.view")) {
|
||||
return { error: DENIED("objednávky vydané") };
|
||||
|
||||
@@ -1753,20 +1753,49 @@ export async function deleteAttendance(
|
||||
requesterId?: number,
|
||||
isAdmin?: boolean,
|
||||
) {
|
||||
const existing = await prisma.attendance.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "Záznam nenalezen" };
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.attendance.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "Záznam nenalezen" };
|
||||
|
||||
// Ownership is enforced ONLY when a requester is supplied, keeping the
|
||||
// signature backward compatible (existing callers pass nothing and skip
|
||||
// the check, behaving exactly as before).
|
||||
if (
|
||||
requesterId !== undefined &&
|
||||
!isAdmin &&
|
||||
existing.user_id !== requesterId
|
||||
) {
|
||||
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
|
||||
}
|
||||
// Ownership is enforced ONLY when a requester is supplied, keeping the
|
||||
// signature backward compatible (existing callers pass nothing and skip
|
||||
// the check, behaving exactly as before).
|
||||
if (
|
||||
requesterId !== undefined &&
|
||||
!isAdmin &&
|
||||
existing.user_id !== requesterId
|
||||
) {
|
||||
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
|
||||
}
|
||||
|
||||
await prisma.attendance.delete({ where: { id } });
|
||||
return { success: true };
|
||||
await tx.attendance.delete({ where: { id } });
|
||||
|
||||
// Inverse of the createLeave charge: deleting a vacation/sick day gives
|
||||
// its hours back to that day's year balance. Clamped at 0 so deleting
|
||||
// rows after a manual balance reset cannot drive *_used negative.
|
||||
if (existing.leave_type === "vacation" || existing.leave_type === "sick") {
|
||||
const hours = Number(existing.leave_hours ?? 0);
|
||||
if (hours > 0) {
|
||||
const field =
|
||||
existing.leave_type === "vacation" ? "vacation_used" : "sick_used";
|
||||
// @db.Date reads back as UTC midnight of the calendar day, so the
|
||||
// UTC year IS the day's calendar year (no TZ window to misattribute).
|
||||
const year = existing.shift_date.getUTCFullYear();
|
||||
const balance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
if (balance) {
|
||||
await tx.leave_balances.update({
|
||||
where: { id: balance.id },
|
||||
data: {
|
||||
[field]: Math.max(0, Number(balance[field]) - hours),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,17 +270,17 @@ export async function refreshAccessToken(
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
// Detected reuse: a token that exists but has ALREADY been rotated
|
||||
// (replaced_at / replaced_by_hash set) is being presented again. This is
|
||||
// the classic refresh-token-theft signature — either the legitimate
|
||||
// client or an attacker is replaying a consumed token. Breach
|
||||
// containment: revoke the WHOLE token family for that user so both the
|
||||
// stolen and the still-valid descendant tokens are invalidated, forcing
|
||||
// a fresh login.
|
||||
if (
|
||||
storedToken &&
|
||||
(storedToken.replaced_at || storedToken.replaced_by_hash)
|
||||
) {
|
||||
// Detected reuse: a token that has ALREADY been rotated (replaced_by_hash
|
||||
// points at its descendant) is being presented again. This is the classic
|
||||
// refresh-token-theft signature — either the legitimate client or an
|
||||
// attacker is replaying a consumed token. Breach containment: revoke the
|
||||
// WHOLE token family for that user so both the stolen and the still-valid
|
||||
// descendant tokens are invalidated, forcing a fresh login.
|
||||
// A token with ONLY replaced_at set was manually TERMINATED (sessions
|
||||
// DELETE / password change set replaced_at without a descendant) — that
|
||||
// is an expected dead token, NOT theft: reject it below as plain invalid
|
||||
// without nuking the user's other sessions.
|
||||
if (storedToken && storedToken.replaced_by_hash) {
|
||||
const reuseUserId = Number(storedToken.user_id);
|
||||
await tx.refresh_tokens.deleteMany({ where: { user_id: reuseUserId } });
|
||||
request.log.warn(
|
||||
@@ -289,7 +289,11 @@ export async function refreshAccessToken(
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
}
|
||||
|
||||
if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
|
||||
if (
|
||||
!storedToken ||
|
||||
storedToken.replaced_at ||
|
||||
new Date(storedToken.expires_at) < new Date()
|
||||
) {
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,43 @@ export function applyPattern(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the {YYYY}/{YY} year embedded in a generated document number by
|
||||
* turning its pattern into a capture regex. Returns null when the pattern
|
||||
* carries no year token or the number doesn't match it (changed pattern,
|
||||
* legacy data) — callers then fall back to their own year.
|
||||
*/
|
||||
export function yearFromGeneratedNumber(
|
||||
number: string,
|
||||
pattern: string,
|
||||
vars: { prefix: string; code: string },
|
||||
): number | null {
|
||||
let yearKind: "YYYY" | "YY" | null = null;
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regexSrc = pattern.replace(
|
||||
/\{(\w+)\}|[^{]+/g,
|
||||
(match, key: string | undefined) => {
|
||||
if (key === undefined) return escapeRe(match);
|
||||
if (key === "YYYY" && yearKind === null) {
|
||||
yearKind = "YYYY";
|
||||
return "(\\d{4})";
|
||||
}
|
||||
if (key === "YY" && yearKind === null) {
|
||||
yearKind = "YY";
|
||||
return "(\\d{2})";
|
||||
}
|
||||
if (key === "PREFIX") return escapeRe(vars.prefix);
|
||||
if (key === "CODE") return escapeRe(vars.code);
|
||||
if (/^N+$/.test(key)) return "\\d+";
|
||||
return escapeRe(match);
|
||||
},
|
||||
);
|
||||
if (yearKind === null) return null;
|
||||
const m = new RegExp(`^${regexSrc}$`).exec(number);
|
||||
if (!m) return null;
|
||||
return yearKind === "YYYY" ? Number(m[1]) : 2000 + Number(m[1]);
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return prisma.company_settings.findFirst({
|
||||
select: {
|
||||
@@ -174,6 +211,14 @@ async function releaseSequence(
|
||||
: "";
|
||||
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
|
||||
|
||||
// The number itself embeds the year it was CONSUMED in (the finalize
|
||||
// year). Prefer that over the caller-supplied year — deleteOffer used to
|
||||
// pass the created_at year, so a draft created in December and finalized
|
||||
// in January released against the wrong year's sequence (never matched,
|
||||
// permanent gap).
|
||||
const effectiveYear =
|
||||
yearFromGeneratedNumber(deletedNumber, pattern, { prefix, code }) ?? year;
|
||||
|
||||
// Lock the sequence row for the duration of the decrement so a concurrent
|
||||
// getNextSequence (which also takes FOR UPDATE) can't read-modify-write the
|
||||
// same row in between — that interleaving could otherwise lose a decrement or
|
||||
@@ -181,13 +226,13 @@ async function releaseSequence(
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.$queryRaw<Array<{ last_number: number }>>`
|
||||
SELECT last_number FROM number_sequences
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${effectiveYear}
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (existing.length === 0) return;
|
||||
|
||||
const highestNumber = applyPattern(pattern, {
|
||||
year,
|
||||
year: effectiveYear,
|
||||
prefix,
|
||||
code,
|
||||
seq: existing[0].last_number,
|
||||
@@ -197,7 +242,7 @@ async function releaseSequence(
|
||||
await tx.$executeRaw`
|
||||
UPDATE number_sequences
|
||||
SET \`last_number\` = ${existing[0].last_number - 1}
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${effectiveYear}
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -424,6 +424,7 @@ async function assertEntryCapAvailable(
|
||||
userId: number,
|
||||
dateFromStr: string,
|
||||
dateToStr: string,
|
||||
excludeEntryId?: number,
|
||||
): Promise<{ error: string; status: number } | null> {
|
||||
const from = toDateOnly(dateFromStr);
|
||||
const to = toDateOnly(dateToStr);
|
||||
@@ -433,6 +434,9 @@ async function assertEntryCapAvailable(
|
||||
is_deleted: false,
|
||||
date_from: { lte: to },
|
||||
date_to: { gte: from },
|
||||
// On update, the entry being moved must not count against itself —
|
||||
// otherwise an unmoved at-cap entry would be uneditable.
|
||||
...(excludeEntryId !== undefined ? { id: { not: excludeEntryId } } : {}),
|
||||
},
|
||||
select: { date_from: true, date_to: true },
|
||||
});
|
||||
@@ -587,6 +591,20 @@ export async function updateEntry(
|
||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||
}
|
||||
|
||||
// Re-check the per-cell cap when the range changes — expanding onto a day
|
||||
// already holding MAX_RECORDS_PER_CELL active entries would create a 4th
|
||||
// that the grid (display-capped at 3) silently hides. The create paths
|
||||
// guard this; the update path must too.
|
||||
if (input.date_from || input.date_to) {
|
||||
const capErr = await assertEntryCapAvailable(
|
||||
existing.user_id,
|
||||
fromStr,
|
||||
toStr,
|
||||
id,
|
||||
);
|
||||
if (capErr) return capErr;
|
||||
}
|
||||
|
||||
// Only validate the category when it actually changes — so editing just the
|
||||
// note of an entry whose category was later retired/hidden still saves.
|
||||
if (input.category && input.category !== existing.category) {
|
||||
|
||||
@@ -108,6 +108,38 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
return { error: "Číslo projektu nelze změnit", status: 400 };
|
||||
}
|
||||
|
||||
// FK pre-validation (mirrors createProject): a dangling id would raise
|
||||
// P2003 at Prisma and surface as a generic 500 — return the same Czech
|
||||
// 400s as the create path. null still clears the FK.
|
||||
if (body.customer_id != null) {
|
||||
const customer = await prisma.customers.findUnique({
|
||||
where: { id: Number(body.customer_id) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customer) return { error: "Zákazník nenalezen", status: 400 };
|
||||
}
|
||||
if (body.responsible_user_id != null) {
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: Number(body.responsible_user_id) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 };
|
||||
}
|
||||
if (body.quotation_id != null) {
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id: Number(body.quotation_id) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!quotation) return { error: "Nabídka nenalezena", status: 400 };
|
||||
}
|
||||
if (body.order_id != null) {
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: Number(body.order_id) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) return { error: "Zakázka nenalezena", status: 400 };
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ["name", "status", "notes"];
|
||||
for (const f of strFields)
|
||||
@@ -283,3 +315,37 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
|
||||
await prisma.project_notes.delete({ where: { id: noteId } });
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Author-only note edit: a note belongs to whoever wrote it (even admins
|
||||
* edit only their own — editing someone else's words would falsify
|
||||
* authorship; admins delete instead). Stamps edited_at for the UI marker.
|
||||
*/
|
||||
export async function updateProjectNote(
|
||||
projectId: number,
|
||||
noteId: number,
|
||||
userId: number,
|
||||
content: string,
|
||||
): Promise<
|
||||
| null
|
||||
| { error: string; status: number; note?: never }
|
||||
| {
|
||||
error?: never;
|
||||
status?: never;
|
||||
note: Awaited<ReturnType<typeof prisma.project_notes.update>>;
|
||||
previousContent: string | null;
|
||||
}
|
||||
> {
|
||||
const note = await prisma.project_notes.findFirst({
|
||||
where: { id: noteId, project_id: projectId },
|
||||
});
|
||||
if (!note) return null;
|
||||
if (note.user_id !== userId) {
|
||||
return { error: "Upravit lze pouze vlastní poznámku", status: 403 };
|
||||
}
|
||||
const updated = await prisma.project_notes.update({
|
||||
where: { id: noteId },
|
||||
data: { content, edited_at: new Date() },
|
||||
});
|
||||
return { note: updated, previousContent: note.content };
|
||||
}
|
||||
|
||||
@@ -597,7 +597,21 @@ export async function confirmIssue(
|
||||
issue.items.map((item) => item.batch_id),
|
||||
);
|
||||
|
||||
// Validate each line's batch has enough quantity
|
||||
// Validate each line's batch has enough quantity — measured against the
|
||||
// COMBINED quantity of all lines on that batch, not per line. Duplicate
|
||||
// same-batch lines (the POST /issues FIFO resolver runs per line with
|
||||
// nothing persisted in between, so two no-batch_id lines for one item
|
||||
// both resolve to the same oldest batch) would otherwise each pass the
|
||||
// per-line check and the decrement loop below would drive the batch
|
||||
// quantity negative — a silent over-issue hidden from stock totals.
|
||||
const qtyByBatch = new Map<number, number>();
|
||||
for (const item of issue.items) {
|
||||
qtyByBatch.set(
|
||||
item.batch_id,
|
||||
(qtyByBatch.get(item.batch_id) ?? 0) + Number(item.quantity),
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of issue.items) {
|
||||
const batch = await tx.sklad_batches.findUnique({
|
||||
where: { id: item.batch_id },
|
||||
@@ -623,7 +637,10 @@ export async function confirmIssue(
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (Number(batch.quantity) < Number(item.quantity)) {
|
||||
if (
|
||||
Number(batch.quantity) <
|
||||
(qtyByBatch.get(item.batch_id) ?? Number(item.quantity))
|
||||
) {
|
||||
return {
|
||||
error: `Šarže ID ${item.batch_id} nemá dostatečné množství`,
|
||||
status: 400,
|
||||
@@ -1089,6 +1106,15 @@ export async function cancelReservation(
|
||||
// Inventory Confirm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Business failure inside the confirm transaction. Thrown (never returned)
|
||||
* from within the $transaction callback so the whole transaction ROLLS BACK
|
||||
* — returning a value from an interactive transaction callback COMMITS, which
|
||||
* would persist an earlier surplus line's corrective receipt while the route
|
||||
* reports failure (each retry would then add more phantom stock).
|
||||
*/
|
||||
class InventoryConfirmError extends Error {}
|
||||
|
||||
/**
|
||||
* Confirms an inventory session:
|
||||
* 1. Validate status is DRAFT
|
||||
@@ -1104,6 +1130,22 @@ export async function confirmInventorySession(
|
||||
): Promise<
|
||||
| { data: { id: number; session_number: string } }
|
||||
| { error: string; status: number }
|
||||
> {
|
||||
try {
|
||||
return await confirmInventorySessionTx(sessionId);
|
||||
} catch (err) {
|
||||
if (err instanceof InventoryConfirmError) {
|
||||
return { error: err.message, status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmInventorySessionTx(
|
||||
sessionId: number,
|
||||
): Promise<
|
||||
| { data: { id: number; session_number: string } }
|
||||
| { error: string; status: number }
|
||||
> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Lock the inventory session row so two concurrent confirms on the same
|
||||
@@ -1277,10 +1319,10 @@ export async function confirmInventorySession(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Nedostatečné množství na skladě";
|
||||
return {
|
||||
error: `Inventarizace položky ID ${item.item_id}: ${message}`,
|
||||
status: 400,
|
||||
};
|
||||
// Throw — NOT return — so earlier lines' writes roll back with us.
|
||||
throw new InventoryConfirmError(
|
||||
`Inventarizace položky ID ${item.item_id}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,14 @@ async function getBrowser(): Promise<Browser> {
|
||||
|
||||
export interface HtmlToPdfOptions {
|
||||
/**
|
||||
* Chromium footerTemplate HTML rendered in the bottom margin of EVERY page
|
||||
* — the only true repeating footer Puppeteer supports (CSS @page margin
|
||||
* boxes are not implemented in Chromium). Styles must be inline and the
|
||||
* font-size explicit (Chromium defaults it to 0); `.pageNumber` /
|
||||
* `.totalPages` spans are substituted by Chromium. When set, the bottom
|
||||
* margin grows to make room and an empty headerTemplate suppresses
|
||||
* Chromium's default date/title header.
|
||||
* Chromium footerTemplate HTML rendered in the bottom margin of EVERY page.
|
||||
* Styles must be inline and the font-size explicit (Chromium defaults it
|
||||
* to 0); `.pageNumber` / `.totalPages` spans are substituted by Chromium.
|
||||
* When set, the bottom margin grows to make room and an empty
|
||||
* headerTemplate suppresses Chromium's default date/title header.
|
||||
* The other valid repeating-footer mechanism: CSS `@page` margin boxes
|
||||
* (`@bottom-center` + `counter(page)`) render since Chrome 131 (Nov 2024)
|
||||
* — the offer PDF uses that route and needs no footerTemplate.
|
||||
*/
|
||||
footerTemplate?: string;
|
||||
/**
|
||||
@@ -88,6 +89,28 @@ export interface HtmlToPdfOptions {
|
||||
export async function htmlToPdf(
|
||||
html: string,
|
||||
options: HtmlToPdfOptions = {},
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
return await renderPdf(html, options);
|
||||
} catch (err) {
|
||||
// The shared browser can die (OOM-kill, crash) between getBrowser()'s
|
||||
// point-in-time `connected` check and the render — every caller holding
|
||||
// the stale handle then fails, even though an immediate relaunch would
|
||||
// succeed. Retry ONCE, and only on the crash signature (browser gone or
|
||||
// disconnected); a still-connected browser means a genuine render error.
|
||||
if (browser && browser.connected) throw err;
|
||||
console.error(
|
||||
"[html-to-pdf] browser disconnected mid-render — relaunching once",
|
||||
err,
|
||||
);
|
||||
browser = null;
|
||||
return renderPdf(html, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPdf(
|
||||
html: string,
|
||||
options: HtmlToPdfOptions,
|
||||
): Promise<Buffer> {
|
||||
const b = await getBrowser();
|
||||
const page = await b.newPage();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node", "vitest/globals"],
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user