Compare commits

..
Author SHA1 Message Date
Jeppe B ef377ae6bd Merge branch 'master' into feat/TRU-149-route-scopes
Qodana / Qodana (pull_request) Waiting to run
Tests / PHP api (required) (pull_request) Waiting to run
Tests / PHP integration (required) (pull_request) Waiting to run
Tests / PHP legacy (required) (pull_request) Waiting to run
Tests / PHP unit (required) (pull_request) Waiting to run
Tests / Edge Agent (required) (pull_request) Waiting to run
Tests / Edge Broker (required) (pull_request) Waiting to run
Tests / Edge Gateway Backend (required) (pull_request) Waiting to run
Tests / Required CI (pull_request) Blocked by required conditions
Tests / Release Manager gate (pull_request) Blocked by required conditions
2026-08-17 21:03:46 +02:00
Jeppe Bandbackend-subagent f0d7d59951 fix(api): post DHL daily goal to internal goal progress webhook (TRU-76) (#405)
## Summary

`GoalsProgressAlertsCron` only consulted the per-department
`slack_webhook`
field when dispatching to Slack. For internal departments — Taulov and
Taastrup are configured as internal via `Slack >
internal_department_ids`
— those per-department webhooks are intentionally empty, so the cron had
no destination to post to and the daily DHL goal never reached the
internal Slack channel.

The dedicated `internal_department_goal_progress_webhook_url` is the
correct destination for these alerts. This change routes the dispatch
through it when **all** of a goal's departments are internal, with a
clean fallback to the existing per-department webhook loop when the
dedicated URL is empty or when the goal includes any non-internal
department. Operator-facing echo lines were added so the cron log
shows exactly which webhook was used for each goal.

## Why "in progress since 21/7"

The legacy cron flow is intact, the new cron-worker is wired up, and
the schedule fires every 60 s as expected. The goal records the right
department IDs. The destination check in the dispatcher silently
matched nothing — the per-department webhook was empty, the fallback to
the default webhook pointed to the wrong channel, and nothing in the
log indicated *which* dispatch path had been taken. Switching the
internal-department branch to the dedicated goal-progress webhook is
the real fix; the diagnostic echo lines prevent this from being silent
in the future.

## Changes

- `services/nginx/app/cron/Cron.php` (GoalsProgressAlertsCron SLACK
  branch): when all linked departments are flagged as internal and the
  dedicated `internal_department_goal_progress_webhook_url` is
  configured, post to that webhook instead of the per-department
  webhooks. Otherwise behave exactly as before.
-
`services/nginx/app/tests/Unit/Cron/GoalsProgressAlertsInternalWebhookTest.php`:
  pin the new dispatch behaviour with four targeted tests covering
  the happy path, the empty-webhook fallback, the mixed/external
  goal path, and the Slack config helper calls.

## Test plan

- `vendor/bin/pest
tests/Unit/Cron/GoalsProgressAlertsInternalWebhookTest.php`
  → 4 passed, 18 assertions.
- `vendor/bin/pest tests/Unit/Cron/` → 34 passed (full cron suite
  still green).
- Manual: after deploy, force-run the task via the existing
  `POST /api/superuser/cron/run` endpoint with body
  `{"job": "goals.progress_alerts"}` and confirm the
  `[CRON] GoalsProgressAlertsCron: goal #N sent to internal goal
  progress webhook (departments: …)` line appears in the cron log and
  the message lands in the configured internal Slack channel.

Fixes TRU-76 (DRIFT 15).

Co-authored-by: backend-subagent <backend@truck-wash.local>
2026-08-17 21:02:06 +02:00
Jeppe B b0ef6aaef9 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 18:45:18 +02:00
Jeppe Bopenclaw bugfixbugfixbugfix-subagent <[email protected]>
1742033bb7 TRU-70: auto-send invoice on the 3rd business day each month (#402)
## TRU-70: DRIFT 9 — Customer rule "auto-send invoice toggle (3rd
business day each month)"

Adds a per-customer rule that auto-sends the customer's invoice on the
3rd business day of each month. The rule is implemented as a new
customer attribute (`autoSendInvoiceThirdBusinessDay`) that can be
toggled through the existing `POST /customer/attributes` endpoint, plus
a daily cron task that, on the trigger day, enqueues every ready
collected invoice for export via the existing `economic_transfer_queue`.

## Linear

- **TRU-70** (DRIFT 9)

## Changes

- **Customer attribute (TRU-70 / DRIFT 9)**
  - `classes/customer_rule_product_restriction_service.php`
Adds `'autoSendInvoiceThirdBusinessDay'` to `SUPPORTED_ATTRIBUTES` so
the existing customer-attributes route can persist the toggle.

- **3rd-business-day service**
  - `classes/auto_send_invoice_third_business_day_service.php` (new)
- Computes the 3rd business day of any month (weekend-aware, holiday
provider override).
    - `runOnce()` is a no-op on every day except the 3rd business day.
- On the trigger day, scans `customer_attributes` for opted-in customers
and loads their ready `collected_order_invoices` (not booked, not
closed, has at least one order, not deleted).
- Enqueues each via `economic_transfer_queue` and returns a summary
`{triggered, customers, collections_scanned, jobs_enqueued,
skipped_already_queued, errors[]}`.
- Public hooks (`loadEligibleCustomerNumbers`,
`loadReadyInvoiceCollections`) and protected `createTransferQueue()` are
designed for unit-test isolation so no live database is required.

- **Cron task (TRU-70 / DRIFT 9)**
  - `modules/economic/cron/tasks.php`
Registers `'economic.auto_send_invoices_third_business_day'` with a 24h
interval, 15 min timeout, priority 25. Anchored in the economic module
because the actual export goes through `economic_transfer_queue`.
  - `cron/Cron.php`
New `AutoSendInvoicesThirdBusinessDay()` handler. Mirrors the
surrounding cron-task conventions (`warn` + `error_log` breadcrumb on
failure) and only logs a one-liner when the trigger fires.

## Tests

- `tests/Unit/Cron/AutoSendInvoiceThirdBusinessDayTest.php` (new, 16
tests)
- The 3rd-business-day computation (weekday-start, weekend-start,
Saturday, 4th-business-day, holiday skip, holiday forward-shift).
- `thirdBusinessDayOfMonth()` helper for the three reference months used
in the spec (Aug/Sep 2026 and Jul 2026).
  - `runOnce()` no-op path on non-trigger days.
  - `runOnce()` summary on trigger day with zero opted-in customers.
  - `runOnce()` summary with customers and ready collections.
  - `runOnce()` per-collection error recording when enqueue throws.
  - `clearOverrides()` reset.
- The new attribute is in
`customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES` and
exposes a stable `ATTRIBUTE` constant.
- `tests/Unit/Cron/CronTaskRegistryTest.php`
Updated the discovery assertion from 22 to 23 definitions and added
coverage that the new `'economic.auto_send_invoices_third_business_day'`
task is discovered with the expected schedule and module.

## Backwards compatibility

- The new attribute is additive; existing `customer_attributes` rows are
unaffected. The default customer has no auto-send rule.
- The cron task is registered in the standard task registry; the
existing `cron_worker` (15s poll) handles the trigger without any new
infrastructure.
- The service gracefully no-ops when the transfer queue is unavailable
in unit-test contexts; the production cron task will surface a `warn()`
breadcrumb if e-conomic is unreachable, exactly like the other economic
cron tasks.

## Verification

```
vendor/bin/pest tests/Unit/Cron/ tests/Unit/Customers/
# 43 passed (199 assertions)
```

---------

Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: bugfix-subagent <[email protected]>
2026-08-17 16:42:14 +00:00
Jeppe B ccae88f4ef Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 17:11:47 +02:00
Jeppe Bandopenclaw bugfix 64c5cd45db feat(api): expose last_wash timestamp on /numberplatescans (TRU-78) (#401)
Resolves TRU-78 (DRIFT 17: License plate scan - show 'last washed'
timestamp on landing page / DHL use case).

The POS landing page already surfaces license plate scans via `GET
/numberplatescans`, but it has no way to tell the operator **when a
plate was last washed**. With DHL trailers going in and out several
times a day, the front-desk needs that hint to decide whether a trailer
needs another wash before pick-up.

## Changes

- **`orders_o::getLastWashTimestampForPlate(string $reg_1): ?string`** —
new helper that returns the `created_at` (MySQL DATETIME) of the most
recent non-deleted order for the plate that has at least one non-deleted
order item. Mirrors the contract used by
`customer_vehicles_o::getLastOrderByPlate()` so the timestamp is always
backed by a real wash.
- **`GET /numberplatescans`** now enriches each scan row with a
`last_wash` key (string or `null`). No breaking change to the existing
payload; new field is additive.
- **New Pest test**
`services/nginx/app/tests/Unit/Orders/OrderLastWashTimestampForPlateTest.php`
— static-analysis assertions for the helper definition and the route
wiring (matches the style of `OrderBookingsCountsRouteWiringTest`).

## Frontend companion

https://github.com/copenhagentruckwash/pleno-vue/pull/335 renders this
`last_wash` in the inline details of each scan row on the POS landing
page (`PosLastScannedLicensePlatesV2.vue`), with an "Aldrig vasket" /
"Never washed" fallback when the API returns `null`.

## Risk

- `getLastWashTimestampForPlate` does one extra indexed read per scan
row (`SELECT id FROM orders WHERE reg_1 = ? AND deleted_at IS NULL`).
The existing `isPlateSeenBefore` call already does the same, so the
route's per-row query count is unchanged in shape.
- The new field is additive and ignored by older clients, so this can
roll forward without a coordinated client release.

Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
2026-08-17 14:35:47 +00:00
Jeppe B 7258c72f60 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 15:41:32 +02:00
Jeppe BandMiniMax M3 Subagent 52d43fc16c fix(api): apply e-conomic discount percentage at line level for customer 35131752 (TRU-73 / DRIFT 12) (#400)
## Summary

Fixes **TRU-73 / DRIFT 12** — invoice format must clearly show the
discount given on all services.

For customers with a global e-conomic discount (e.g. `kd` customer
`35131752` with a 15% discount), the discount was being silently dropped
on draft invoice lines. E-conomic's draft invoice line API requires
`discountPercentage` on each line, so an aggregate `TotDiscount` line is
ignored when the customer has a per-line discount configured. The fix
applies the customer discount at the line level.

## What changed

-
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
`customer_discount_percentage` argument and combine it with the per-item
discount using `max(per_item, customer)`. The aggregate `TotDiscount`
line is suppressed when a customer-level discount is in play.
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
— logs swallowed missing-currency-price errors so silently-missing
discounts become visible in the application log.
-
`services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
— forwards the customer discount percentage to the draft builder.
- `services/nginx/app/objects/collected_order_invoices_o.php` — resolves
the customer discount via Redis cache + e-conomicCustomers and passes it
to the draft builder.
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new test class covering the customer 35131752 15% case plus edge cases
(per-item + customer discount combined, clamping to 0..100,
zero-discount baseline).
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated for the new parameter and the customer-discount guard on the
aggregate `TotDiscount` line.
-
`services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
— updated to thread the new parameter through the batch transfer
pipeline.
- `documentation/economic/invoice-discount-format-drift12.md` — new doc
with the before/after invoice layout (the example Jimmy asked for in the
DRIFT 12 description).

## Example (for Jimmy)

Customer 35131752 ("kd") with 15% global e-conomic discount, one wash
line at 100,00 DKK.

### Before
```
Vask                                     1 ×   100,00 DKK     100,00
Subtotal                                                    100,00 DKK
Rabat (15%)                                                  0,00 DKK   ← silently dropped
Total                                                       100,00 DKK
```

### After
```
Vask (15% rabat)                        1 ×   100,00 DKK     100,00
                                                       Rabat:  -15,00 DKK (15%)
Subtotal                                                    100,00 DKK
Rabat                                                        15,00 DKK
Total                                                         85,00 DKK
```

## Test plan

- [x] New `EconomicInvoiceDraftCustomerDiscountTest` covers: 15%
customer discount applied at line level, per-item + customer discount
combined using `max`, clamping to 0..100, zero-discount baseline.
- [x] `EconomicInvoiceDraftDiscountLineModeWiringTest` updated and still
passes.
- [x] `CollectedInvoiceEconomicBatchTransferWiringTest` updated for the
new parameter.
- [ ] Run full `php-ci-test.sh unit` locally to confirm nothing else
regressed.

## Linear

Closes TRU-73 (DRIFT 12).

🤖 Generated via the TRU-73 pickup cron run.

---------

Co-authored-by: MiniMax M3 Subagent <fix@truckwash.local>
2026-08-17 13:41:10 +00:00
Jeppe B cae91d6a11 fix(auth): grant customer role WRITE scopes for own data (TRU-149) 2026-08-17 13:28:19 +00:00
Bugfix Subagent 9025a8af6e fix(auth): add scope checks to remaining protected routes and fix scope test contract
Three fixes for the failing CI checks (PHP api, PHP integration):

1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments
   are treated as needles. The second 'description' argument was
   being treated as a needle, causing every file to fail. Removed the
   misleading second argument.

2. Added ScopeMiddleware::requireScope() calls and the matching
   Scope/ScopeMiddleware imports to 15 protected route files that
   the integration test contract requires.

3. documentation/auth/route-scope-audit.md: added the missing
   Scope::SUPERUSER_WRITE reference and a constants reference table.

Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the
legacy test manifest.
2026-08-17 13:22:09 +00:00
Jeppe B f32bca2ac7 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 15:10:19 +02:00
d2528c5ed7 perf(db): add fulltext index for customer search (TRU-62) (#398)
## Problem

DRIFT 4: Customer tab search takes ~10s. Transaction history is
similarly slow.

## Root cause

`system_search_economic_customer_index` table (~50k+ rows) is searched
with `LIKE '%term%'` queries. MySQL does a full table scan because there
is no fulltext index. The wrapping code uses `LIKE` against a
stringified row.

## Fix

- New migration
`2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php`
creates a MySQL FULLTEXT index on the searchable columns.
- `system_search_economic_customer_index.php` switched to `MATCH(cols)
AGAINST (?)` when the index is present, with fallback to LIKE for older
MySQL versions.
- `system_search_service.php` updated to use the new fulltext query
path.

## Investigation doc

`documentation/perf/customer-search-slow-investigation.md` documents:
- The exact SQL that was slow
- EXPLAIN output
- Table sizes
- Why this is the bottleneck
- Estimated impact after fix

## Tests

`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` (437
lines) covers the new fulltext-backed search behavior.

## Estimated impact

| Search type       | Before | After  |
|-------------------|--------|--------|
| Customer search   | ~10s   | <500ms |
| Transaction hist. | ~10s   | <500ms |

Refs: TRU-62, TRU-4 (DRIFT 4)

---------

Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 13:03:34 +00:00
Jeppe B 0c57460540 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 14:55:13 +02:00
Jeppe Bandperf-investigator <[email protected]> 3e39a50a4f feat(xl-vask): TRU-71 replace 'Stripe' wording in invoice email (#399)
Implements **TRU-71 (DRIFT 10)** for the api repo: rewrites the
customer-facing Stripe invoice email body so it no longer exposes the
payment-processor name 'Stripe' to the customer. The artefact is now
described as a *betalingslink* (payment link) in plain Danish, matching
the wording used by the rest of the system.

## Changes
-
services/nginx/app/modules/email/templates/email_template_stripe_invoice.php
— replaces 'på Stripe' with 'et betalingslink til din faktura' in the
customer body.
- services/nginx/app/tests/auth/StripeInvoiceEmailTemplateTest.php (new)
— regression test that asserts the rendered HTML contains no 'stripe'
token and includes the new 'betalingslink' wording.

## Tests
- php8.4
services/nginx/app/tests/auth/StripeInvoiceEmailTemplateTest.php → PASS
- Full Pest Unit suite: 1348 tests pass, 11 pre-existing failures in
Bird/Scanner/SchemaHealthCheck/Selfserve/Tooling — unrelated to this
change.

Refs: TRU-71. Frontend companion PR copenhagentruckwash/pleno-vue ships
the same wording change in InvoiceOrdersPagination.vue.

---------

Co-authored-by: perf-investigator <[email protected]>
2026-08-17 12:37:46 +00:00
Bugfix Subagent fd70864434 fix(auth): keep public driver registration endpoints publicly accessible (TRU-149)
The scope-middleware injection on the public driver registration
endpoints (POST /subusers and POST /subusers/me) breaks the public
registration contract — new drivers cannot hold a scope before they
exist, so requiring one would make self-registration impossible.

PublicSubuserRegistrationContractTest enforces the literal
'POST /subusers → registerPublicSubuser' / 'POST /subusers/me →
registerPublicSubuser' signature. Adding the ScopeMiddleware call
violates that contract and fails the test.

Drop the ScopeMiddleware::requireScope() call from both public
registration handlers; the in-method abuse controls (recaptcha,
rate limits, MySQL GET_LOCK) remain the only gate, as before.

Fixes CI on PR #397 (Required CI, PHP unit, PHP api, PHP integration).
2026-08-17 12:12:47 +00:00
Jeppe B e9f1b8f880 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 13:55:32 +02:00
Jeppe BandTRU-198 Subagent 18dc8e6e9c feat(auth): API key foundation (TRU-143+144+145) (#396)
## Summary

API key foundation for the Truck Wash API: data model, key generation,
and scope system.

Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings

## Linear

- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings

## Files

**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record

**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests

**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).

## Schema

```sql
CREATE TABLE api_keys (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    key_id VARCHAR(64) NOT NULL,
    key_hash VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    role VARCHAR(32) NOT NULL,
    scopes JSON NULL,
    customer_id BIGINT UNSIGNED NULL,
    created_by BIGINT UNSIGNED NULL,
    last_used_at TIMESTAMP NULL,
    expires_at TIMESTAMP NULL,
    revoked_at TIMESTAMP NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_api_keys_key_id (key_id),
    INDEX idx_api_keys_customer (customer_id),
    INDEX idx_api_keys_key_hash (key_hash),
    INDEX idx_api_keys_revoked (revoked_at),
    INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```

## Key format

```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```

- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.

## Role → scope defaults

| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |

Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.

## Design notes

- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.

## Checklist

- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files

---------

Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
2026-08-17 13:54:04 +02:00
Jeppe B 6cf94b8cd9 Merge branch 'master' into feat/TRU-149-route-scopes 2026-08-17 13:45:15 +02:00
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).

What this PR does:
- Audits every existing route and documents required scope per route
  (see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)

Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.

Refs: TRU-149
2026-08-17 11:43:13 +00:00
Jeppe B 4b08453ee2 docs(economic): audit e-conomic invoice templates (TRU-197) (#394)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 13:38:09 +02:00
ae4b7aef07 docs(economic): map draft-invoice layout code paths (TRU-198) (#395)
## Summary

Maps every code path in the API repo that creates an e-conomic draft
invoice or sends draft lines, and documents which paths pick a layout,
which one they pick, and how the planned **with-discounts /
without-discounts** two-layout selection applies.

**Key finding:** the two envelope creators already implement a
discount-aware selector. No code change is required for the TRU-197
rollout — only the two `invoice*LayoutNumber` config variables need to
be set in the `economic` module.

## Findings at a glance

- **22** code paths in `services/nginx/app/` create or send draft
invoices (2 envelope creators + 6 line-add paths + 14
caller/selector/helper paths)
- **2** paths currently pick a layout — both already discount-aware
- **0** paths need updating for the 2-layout rollout
- **2** config variables drive the selection: `invoiceLayoutNumber` and
`invoiceDiscountLayoutNumber` (already wired into `economic::$config`
and the OpenAPI schema)

## The two selectors

1. `economic_invoice_draft_mo::resolveLayoutNumber()` at
`services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php:115`
— used by `createInvoiceDraftExample()` for the single-order draft flow.
2. `collected_order_invoices_o::resolveInvoiceLayoutNumber()` at
`services/nginx/app/objects/collected_order_invoices_o.php:673` — used
by `createInvoiceDraft()` for the collected-invoice flow.

Both return `invoice_discount_layout` if any item has a non-zero
discount, otherwise `invoice_layout`. They throw if the discount layout
is required and `invoiceDiscountLayoutNumber` is unconfigured.

## Document

`documentation/economic/layout-selection-flow.md` — full inventory
table, current/desired state, and migration plan.

## Related

- TRU-197 — `documentation/economic/invoice-template-audit.md`
- TRU-193 — `documentation/economic/export-field-audit.md`
- PR #391 — `economic_export_sanitizer`

Refs: TRU-198

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
2026-08-17 13:05:13 +02:00
ea9bdbe12c fix(economic): audit and sanitize additional export fields (TRU-193) (#393)
## Summary

Audit and (where needed) fix additional fields in the e-conomic export
path. PR #391 covered the main order.* and order_item.* fields; this PR
covers the remaining fields that could carry special characters.

## Changes

1. Pre-flight validation (defense in depth): 5 rules per line throw on
violation.
2. addTextLine() and addProductLine() now sanitize at insertion (defense
in depth).
3. Recipient block sanitization in add(): name/address/zip/city via
sanitizeTextLine, EAN via preg_replace.
4. Audit document: documentation/economic/export-field-audit.md.
5. Tests: 94 tests / 171 assertions (14 + 19 + 6 + 24 new tests).

## Refs
- TRU-193, TRU-188, TRU-194, PR #391

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: Bugfix Subagent <bugfix@subagent.local>
2026-08-17 12:52:04 +02:00
Jeppe BandOpenClaw b16b2cfe44 fix(economic): sanitize user-input fields to prevent 400 errors (#391)
## Problem

E-conomic API returns HTTP 400 when text-line descriptions contain
certain characters. The most common case is `/` in the order reference
field, which causes the entire draft-invoice export to fail.

## Root cause

When `order.reference` (or notes, reg_*, po) contains `/`, e-conomic's
text-line validation rejects the entire draft with HTTP 400. Same for
control characters and very long strings.

## Fix

Adds `economic_export_sanitizer` class that sanitizes all user-input
fields flowing into e-conomic:

- `/` → `-` (the reported 400 trigger)
- Control chars stripped (\x00-\x1F except \t and \n)
- Tab and newline → single space
- Whitespace normalized and trimmed
- Lengths capped (text 250, product 50, description 500) with `...`
suffix
- Multibyte safe (æ, ø, å, emoji, Chinese)

## Applied to

In `economic_invoice_draft.php`:
- `order.po`
- `order.reference` (PRIMARY FIX for the reported issue)
- `order.notes`
- `order.reg_1/2/3`
- `order_item.reference`
- `order_item.notes`
- `product.description`
- `product.productNumber`
- `department_name`

## Test coverage

- 31 unit tests with 45 assertions
- All edge cases (null, empty, control chars, multibyte, very long,
HTML, control chars in every position)
- Lint and test suite both pass

## Linear

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196

Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 12:24:04 +02:00
Jeppe B 339b6eb7a5 fix(api): install + start cron-worker systemd service on deploy (#390)
Brings cron-worker online in production via systemd. Closes verify-api-cron.py liveness alert.
2026-08-17 11:00:23 +02:00
935b2d58ce fix(api): remove broken Coolify cron-worker auto-deploy (#389)
## Summary

The Coolify-based auto-deployment of a separate `cron` worker app after
every API deploy was never reliable. This PR removes the ~800 lines of
dead auto-deploy logic from `release_manager.php` while keeping the
underlying cron mechanism (`cron_worker.php`, `cron_scheduler.php`, the
docker-compose `cron-worker` service) intact.

## Changes

- **`release_manager.php`** (-818 lines)
- Removed 19 private methods: `deployCronWorker*`, `cronWorker*`,
`cronWorkerAutoprovision*`, etc.
- Removed 3 constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`,
`CRON_WORKER_DESIRED_COUNT`
- Kept `cronWorkerStatus()` but rewrote as a direct DB query (no Coolify
dependency)
- **`tests/Unit/ReleaseManager/ReleaseManagerTest.php`** (-208 lines,
removed 9 cron-worker tests)
- **`tests/Unit/Cron/CronWorkerWiringTest.php`** (rewritten — now
asserts removed wiring is GONE)
- **`docs/CRON_PLAN.md`** (new — comprehensive plan)

## What replaced the broken auto-deploy

- The cron worker runs as part of the main API docker-compose stack (the
`cron-worker` service is unchanged)
- New verification cron `1bb56ba8-2f3e-4bea-baa2-39801ea88ea8` runs
`/workspace/scripts/verify-api-cron.py` every 5 min
- Alerts to Slack #ai-daily (`C0AM3E43249`) if no fresh heartbeat in 10+
min

## Test results

- 4/4 cron tests pass
- 54/54 ReleaseManager tests pass
- Full Unit suite: **1279 passed** (same 7 pre-existing failures on
master, unchanged)
- `php -l` passes on all modified files

## Plan

See `docs/CRON_PLAN.md` for the full audit, plan, and acceptance
criteria.

🤖 Generated with [OpenClaw](https://docs.openclaw.ai)

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-17 10:16:03 +02:00
Jeppe Bandbugfix 4c6b60c9f2 fix(api): self-healing schema bootstrap on every request (TRU-77 follow-up) (#387)
## Summary

Makes the database **self-healing** — every request auto-runs all
`*_schema_bootstrap::ensureSchema()` after `$db->connect()`. This
catches the "merged-to-master-but-migration-never-applied-to-prod"
failure mode that just bit us with TRU-77 (`invoice_email` column).

## Why

PR #383 added a pre-deploy schema step to `deploy.yml`. Correct, but
requires GitHub secrets (`DEPLOY_SSH_KEY`, `DEPLOY_USER`,
`SMOKE_BASE_URL`) that aren't set on the `api` repo yet. Until those
secrets exist, the pre-deploy step is skipped and migrations never reach
production. Result: API still references `invoice_email` but the column
doesn't exist → `Unknown column 'invoice_email' in 'SELECT'`.

## Fix

- New class `classes/schema_bootstrap_runtime.php`:
  - Auto-discovers all `*_schema_bootstrap.php` files in `classes/`
  - Calls `ensureSchema()` on each
  - Memoized per PHP process (`private static bool $ran = false`)
  - One failure does not block others (logged, not thrown)
- New hook in `services/nginx/app/index.php` right after
`$db->connect()`:
  ```php
  try {
      \classes\schema_bootstrap_runtime::runAll();
  } catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' .
$e->getMessage());
  }
  ```
- New test: `tests/Unit/SchemaBootstrapRuntimeTest.php` (3 cases)

## Safety

Each existing `*_schema_bootstrap` is **additive + idempotent**:
- `SHOW COLUMNS` check before any `ALTER`
- `ALTER TABLE ADD COLUMN` only if missing
- Per-class `private static bool $initialized = false` short-circuit
- Errors logged but never break the request

So: first request after deploy adds missing columns. Every subsequent
request hits the in-process `$ran` short-circuit (~microseconds). The
new column then exists, the API works, error goes away.

## Test plan

1. Wait for CI (PHP unit + integration)
2. Merge to master
3. Production auto-deploys (or manual re-deploy if secrets not set)
4. Hit the failing endpoint — first request will auto-migrate, response
should be 200
5. Verify with `GET /api/admin/schema-check` that all columns are
present

## Rollback

If anything goes wrong, revert the merge commit. The runtime class only
auto-discovers files matching `*_schema_bootstrap.php`; removing it
reverts the system to the pre-deploy-step-only behavior.

---

**Closes** the TRU-77 follow-up: the "Unknown column 'invoice_email' in
'SELECT'" error should never recur, because the code now self-heals
regardless of whether the deploy pre-deploy step ran.

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 23:00:03 +02:00
18092b271e feat(api): schema health check + pre-deploy migration runner (fixes TRU-77 production error) (#383)
## Problem

Production was returning:
```json
{"success":false,"data":{"message":"Internal server error: Unknown column 'invoice_email' in 'SELECT'"}}
```
when authenticating as a superuser. The migration that adds
`users.invoice_email` was merged to master in api#381 but never applied
to the production database.

## Fix

- New `GET /api/admin/schema-check` endpoint — returns 503 with explicit
list of missing columns if any are absent (instead of a generic 500)
- `scripts/run-schema-bootstraps.php` — auto-discovers and runs every
`*_schema_bootstrap` class on the live database (additive, idempotent)
- `scripts/schema-health-check.php` — CLI tool for the same check, used
by deploy pipelines
- New Pest contract test `SchemaHealthCheckTest` — verifies the test DB
has every required users column and the schema-check endpoint works
- `deploy.yml`: pre-deploy step runs the bootstrap runner, smoke test
also runs the schema check, Slack alert on failure

## What this prevents

- Future migrations being merged without being applied to production
- Silent failures (generic 500) when a column is missing
- Repeated manual investigation of the same root cause

Refs: TRU-77 (the original bug), api#381 (the original PR)

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: bugfix-subagent <bugfix-subagent@truckwash.local>
Co-authored-by: OpenClaw Bugfix Agent <bugfix@openclaw.local>
2026-08-16 22:30:03 +02:00
Jeppe Bandbugfix 76ad696691 docs: mark pen-test plan as CANCELLED (TRU-80, no budget approved) (#386)
## Summary

Marks the pen-test plan document as **CANCELLED** per Jeppe's
instruction 2026-08-16 20:00 UTC.

External pen-test engagement is **not** happening at this time (no
budget approved). The plan document is kept as a planning artefact for
future reference, but explicitly bannered as CANCELLED so future agents
and engineers do not assume this is an active project.

## Changes

- Added  CANCELLED banner to the top of
`documentation/security/pen-test-plan.md`
- Banner includes: status, reason, meaning, owner, and how to re-open in
the future
- Original content preserved below the banner (296 lines → 304 lines
with banner)

## Context

- TRU-80 (Linear): remains in **Done** state (planning artefact
complete, execution not authorised)
- Qodana Cloud: remains active (no workflow changes)
- GitHub Dependabot + secret scanning: remain active (free tier)
- This PR supersedes PR #385 (which was rolled back because it also
removed Qodana by mistake)

## Checklist

- [x] No external vendor will be engaged
- [x] No workflow changes
- [x] No secret removals
- [x] Original plan content preserved

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 22:20:03 +02:00
Jeppe Bandbugfix 80dca6b5f0 docs(security): white-hat pen test plan + engagement scope (TRU-80) (#384)
## Summary

TRU-80 (DRIFT 19): white-hat penetration testing of the platform —
action
required was to *plan and schedule* the engagement and define scope and
budget. This PR delivers the planning artefact.

## What this PR adds

- `documentation/security/pen-test-plan.md` — full engagement plan:
  - **Scope (in):** API (116 route files + Stripe / Limble / Scanner /
    Edge Gateway / Bird / Self-Serve Studio modules), pleno-vue web SPA,
    Capacitor iOS/Android mobile, infra & cross-cutting (TLS, headers,
    subdomains).
  - **Out of scope:** third-party SaaS internals (Stripe, Economic,
    Shelly, Limble, WP), OT/physical, DoS, social engineering,
    transitive-dep audit.
  - **Methodology:** OWASP ASVS L2 (stretch L3 on auth + payment), WSTG,
    MASVS, 8 phases over ~12 vendor-days.
  - **Rules of engagement**, deliverables, daily standup channel,
    re-test terms.
- **Budget:** 180 000 – 220 000 DKK + 25 000 retainer (mid-tier vendor),
    with boutique and Big-4 tiers for comparison. Total envelope with
    contingency ≈ 230 000 DKK.
  - **Schedule:** vendor RFP late Aug, engagement week 39 (2026-09-22),
    final report mid-Oct, re-test mid-Nov 2026.
  - **Pre-engagement hardening checklist** for engineering to land in
    parallel (HSTS, CSP, cookies, CSRF, webhook signature verification,
    rate-limits, SCA in CI, Capacitor WebView hardening, secrets audit).
    Doubles as re-test acceptance criteria.
  - **Open questions** for management (budget cap, contract owner,
    language, retainer approval, scope trim).
- `documentation/security/README.md` — index for future security
    artefacts. Per convention, raw pen-test reports stay out of the
    public repo; only planning docs and re-test acceptance letters are
    committed.

## Why a docs PR, not code

TRU-80 is a planning task (DRIFT 19), not a code defect. The deliverable
is the engagement plan itself so management can sign off on budget and
timeline. Once approved, the actual engagement will be a separate SOW
with the selected vendor.

## Test plan

- [x] Plan reviewed against the issue description
  (Plan + schedule + scope + budget).
- [x] Branch name follows `fix/tru-80-<short-slug>` convention.
- [x] Commit message references TRU-80.
- [ ] Management sign-off on §6 budget and §6.3 schedule.
- [ ] Vendor RFP and selection (separate Linear sub-tasks to be opened
      off this plan).

## Linear

- Closes TRU-80 (planning deliverable for DRIFT 19).
- After merge, follow-up issues will be opened for: vendor RFP, vendor
  selection, contract / NDA, pre-engagement hardening checklist items
  (§7 of the plan).

Refs: https://linear.app/truck-wash-aps/issue/TRU-80

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 21:46:03 +02:00
7c4acc636c fix(api): post new-booking Slack notifications only for pickups (TRU-106) (#378)
## Summary

SENERE 14 / **TRU-106**: only PICKUP bookings should post a new-booking
notification to the department Slack channel. Drop-off bookings
(pickup_bool = 0) are now silently filtered out. SMS and email delivery
paths are unaffected.

## Change

Minimal, non-refactor:

- New `classes\slack::send_new_booking_notification(...)` that wraps
`format_new_booking` + `send_webhook_message` and short-circuits when
`pickup_bool === false`. Returns bool (sent vs. filtered).
- Two call sites in `objects/bookings_o.php` (`addOrUpdate` +
`notifyNewBooking`) updated to use the new wrapper. Same arguments, no
other behavior changes.
- Other Slack notification types (customer registration, internal
department goal progress, unfulfilled bookings) are deliberately
untouched.

## Tests

New Pest test `tests/Unit/Slack/SlackNewBookingPickupFilterTest.php`:

- pickup -> notification sent (one webhook call, message contains the
booking id)
- drop-off -> no notification, no log entry
- no webhook configured -> no notification
- webhook URL never appears in log payload

PHP isn't installed in this sandbox; the test was code-reviewed against
the existing `SlackCustomerRegistrationWebhookTest` pattern (subclass +
it()/expect()). Please run `./vendor/bin/phpunit
tests/Unit/Slack/SlackNewBookingPickupFilterTest.php` on CI / locally to
confirm.

## Risk

Low. Adds an early-return filter inside a new method; existing call
sites already pass pickup_bool as a boolean. No DB schema change, no new
dependency, no config file change.

Closes TRU-106

---------

Co-authored-by: backend-subagent <agent@openclaw.local>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix <bugfix@openclaw.local>
Co-authored-by: Truck Wash Bugfix Bot <bugfix@truckwash.local>
2026-08-16 21:09:10 +02:00
164 changed files with 9252 additions and 1220 deletions
+8
View File
@@ -86,6 +86,14 @@ jobs:
fi
# Restart generic services
sudo systemctl reload nginx || true
# Install and start the cron-worker systemd service (long-running scheduler)
if [ -f services/nginx/app/resources/cron-worker.service ]; then
sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service
sudo systemctl daemon-reload
sudo systemctl enable cron-worker || true
sudo systemctl restart cron-worker || true
echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)"
fi
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
+127
View File
@@ -0,0 +1,127 @@
name: Verify e-conomic Live
# Live verification of e-conomic export sanitization.
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
on:
workflow_dispatch:
inputs:
customer_number:
description: 'e-conomic customer number to test against'
required: false
default: '12345679'
type: string
dry_run:
description: 'Dry run (skip actual API calls, just verify env)'
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'
schedule:
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
- cron: '0 6 * * 1'
concurrency:
group: live-verify-economic
cancel-in-progress: false
permissions:
contents: read
jobs:
verify:
name: Live verify e-conomic draft flow
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
with:
php-version: '8.4'
extensions: curl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Dry-run mode (verify env only)
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
set -euo pipefail
echo "Dry-run mode: checking environment..."
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
exit 1
fi
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
exit 1
fi
# Mask secrets in logs
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
echo "All env vars present. Re-run with dry_run=false to do a live test."
- name: Run live verification (creates and cleans up a real draft)
if: ${{ github.event.inputs.dry_run == 'false' }}
run: |
set -euo pipefail
cd /workspace/copenhagentruckwash/api
# Use the script that's checked in
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
if [ -f scripts/verify-economic-drafts-live.php ]; then
php8.4 scripts/verify-economic-drafts-live.php
else
# Fallback: use the script from /workspace (where we keep platform scripts)
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
php8.4 /workspace/scripts/verify-economic-drafts-live.php
else
echo "::error::Live verification script not found"
exit 1
fi
fi
- name: Upload verification logs
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: live-verify-logs
path: |
/tmp/verify-economic-*.log
.tmp/verify-economic-*.log
if-no-files-found: warn
retention-days: 7
- name: Notify Slack on failure
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
run: |
set -euo pipefail
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "$(cat <<EOF
{
"channel": "$AI_DAILY_CHANNEL",
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
EOF
)"
fi
+115
View File
@@ -0,0 +1,115 @@
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
## Audit findings
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
- That separate app runs `php index.php run cron-worker` as a long-running process
- Tracks worker heartbeats in a `cron_worker_state` table
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
for creating a new application for the cron worker are not stable/reliable in
our setup.
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
is sound. The Docker compose files already define a `cron-worker` service
that runs the long-running process. The auto-deploy logic is just trying to
maintain a separate Coolify app for the same purpose — and failing.
## The plan
### 1. Remove the broken auto-deploy logic
Delete or no-op the following from `release_manager.php`:
- `cronWorkerStatus()`
- `deployCronWorker()`
- `deployCronWorkerForApiTarget()`
- `deployCronWorkerAfterApiDeployment()`
- `cronWorkerAutoprovisionEnabled()`
- `cronWorkerAutoprovisionRequired()`
- `cronWorkerTarget*()` (5 methods)
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
- `cronWorkerDeployContext()`
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
- `cronWorkerChannels()`, `cronWorkersForTarget()`
- `cronWorkerSourceFromCronTarget()`
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
- The `$result['cron_worker'] = ...` call after API deployment
Keep:
- `cron_worker.php` class (the actual worker)
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
- The `cron-worker` service in `docker-compose*.yml`
- The `cron-worker` case in `cli.php`
### 2. Remove the corresponding tests
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
### 3. Add a reliable 5-min cron mechanism
Two-layer approach:
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
tasks that need to run frequently (60s intervals, etc.). Started automatically
with the rest of the stack.
2. **System cron / health-check loop** — verifies the cron-worker is alive every
5 min. If no fresh heartbeat in 10 min, alert.
This replaces the broken auto-deploy with a simple, observable contract.
### 4. Add a verification harness
`/workspace/scripts/verify-api-cron.py`:
- Hits the API's `cronWorkerStatus` endpoint
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
- If no fresh heartbeat in 10 min, post to #ai-daily
- Run every 5 min via a new cron job
### 5. Update documentation
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
- `openapi.yaml` — remove `cron_worker_status` route documentation
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
## Acceptance criteria
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
- [ ] No tests reference removed methods
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
- [ ] PR created, tests pass, merge
## Risk
- **Removing `deployCronWorker*` could break live deployments** if someone is
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
HTTP route returning a friendly "removed" message instead of deleting it.
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
break dashboards. Mitigation: replace the route handler with a direct query
to `cron_worker_state` so the response shape is preserved.
## Steps
1. Create a feature branch `fix/remove-coolify-cron-worker`
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
6. Edit `cli.php`: no change needed (cron-worker case still works)
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
11. Run the test suite locally
12. Push branch, create PR, get user review
+245
View File
@@ -0,0 +1,245 @@
# Route Scope Audit — TRU-149
**Generated:** 2026-08-17
**Scope:** All route files under `services/nginx/app/routes/`
**Total route files:** 116
**Total route handlers:** ~600+
## Scope Definitions
The scope set is defined in `services/nginx/app/classes/auth/scope.php`
(this is the local TRU-149 stub — TRU-145 will replace/extend it).
| Constant | String | Used by |
|---|---|---|
| `CUSTOMER_READ` | `customer:read` | GET on customer resources |
| `CUSTOMER_WRITE` | `customer:write` | POST/PUT/DELETE on customer resources |
| `BOOKING_READ` | `booking:read` | GET on bookings / time-bookings |
| `BOOKING_WRITE` | `booking:write` | POST/PUT/DELETE on bookings |
| `SUBUSER_READ` | `subuser:read` | GET on subuser management |
| `SUBUSER_WRITE` | `subuser:write` | POST/PUT/DELETE on subuser management |
| `INVOICE_READ` | `invoice:read` | GET on invoices / invoicing period |
| `INVOICE_WRITE` | `invoice:write` | POST/PUT/DELETE on invoices |
| `SUPERUSER_READ` | `superuser:read` | GET on superuser-only resources (cron, replication, coolify, system status) |
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources (cron run, replication trigger, intimidation) |
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources |
### Role → Scope mapping
Defined in `Scope::forRole()`. Centralised so role changes don't
ripple through every route.
| Role | Scopes |
|---|---|
| `superuser` | all 10 |
| `admin` | all except `SUPERUSER_*` (8) |
| `customer` | `CUSTOMER_READ`, `BOOKING_READ`, `INVOICE_READ` (3) |
| `subuser` | `BOOKING_READ`, `BOOKING_WRITE` (2) |
| (default) | none — deny |
## Routes by group
The full per-route audit is in the "Route inventory" section below.
Here is the high-level grouping used when applying scopes.
### Admin / superuser routes (require `SUPERUSER_*` or `CUSTOMER_*` write)
| File | Endpoints | Scope applied |
|---|---|---|
| `adminRoute.php` | `GET /admin/schema-check` | `SUPERUSER_READ` (intentionally anonymous infra check, but scoped for safety) — see TODO |
| `cronRoute.php` | `GET/POST /superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserCoolifyRoute.php` | `/superuser/coolify/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserDepartmentRoute.php` | `/superuser/departments/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserReplicationRoute.php` | `/superuser/replication/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserSecurityRoute.php` | `/superuser/security/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserSystemStatusRoute.php` | `/superuser/system-status/*` | `SUPERUSER_READ` |
| `superuserCustomerRuleProductRestrictionsRoute.php` | `/superuser/customer-rule-product-restrictions/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `customerCodeDepartmentRoute.php` | `GET/POST /admin/customer/code` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerSearchRoute.php` | `POST /customers/search` | `CUSTOMER_READ` (admin) |
| `customerSearchRoute.php` | `POST /customers/import` | `CUSTOMER_WRITE` (admin) |
| `washCertificateDebugRoute.php` | `/admin/wash-certificate-debug/*` | `SUPERUSER_READ` |
### Customer routes (read mostly, write selectively)
| File | Endpoints | Scope |
|---|---|---|
| `customerAttributes.php` | `GET/POST/DELETE /customer/attributes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerDefaultDepartmentRoute.php` | `/customer/department/default` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerFixedPricingRoute.php` | `/customer/pricing/fixed` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerNotes.php` | `/customer/notes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerTimeBookingsRoute.php` | public time-booking reads | none (public) |
| `customersRoute.php` | `/customers*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `usersRoute.php` | `/users*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
### Booking routes
| File | Endpoints | Scope |
|---|---|---|
| `bookingsRoute.php` | `/bookings*` (all variants) | `BOOKING_READ` / `BOOKING_WRITE` |
| `departmentTimeBookingsRoute.php` | `/department/timebookings/.../public` | none (public read) |
| `departmentTimeBookingsRoute.php` | `/department/timebookings/...` (auth) | `BOOKING_READ` / `BOOKING_WRITE` |
| `orderBookingRoute.php` | `/order/booking*` | `BOOKING_READ` / `BOOKING_WRITE` |
### Invoice routes
| File | Endpoints | Scope |
|---|---|---|
| `invoicesRoute.php` | `/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `orderInvoicesRoute.php` | `/order/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `userInvoicesRoute.php` | `/user/invoices*` | `INVOICE_READ` |
| `economicInvoiceRoute.php` | `/economic/invoice*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `InvoicingPeriodRoute.php` | `/superuser/invoicing/period*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
### Subuser routes
| File | Endpoints | Scope |
|---|---|---|
| `subusersRoute.php` | `/subusers*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
| `subuserGrantsRoute.php` | `/subuser-grants*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
### Order routes
| File | Endpoints | Scope |
|---|---|---|
| `orderRoute.php` | `/order*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `ordersRoute.php` | `/orders*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `orderItemsRoute.php` | `/order/items*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `userOrdersRoute.php` | `/user/orders*` | `CUSTOMER_READ` |
### Department routes (admin / superuser territory)
| File | Endpoints | Scope |
|---|---|---|
| `departmentsRoute.php` | `/departments*` | `CUSTOMER_READ` (department meta) |
| `departmentLanesRoute.php` | `/department/lanes*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentGatesRelaysRoute.php` | `/department/gates*`, `/department/relays*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentGoalsRoute.php` | `/goals/department*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentNotificationSmsRoute.php` | `/department/notification/sms*` | `CUSTOMER_WRITE` |
| `departmentDailyReportsRoute.php` | `/departments/daily-reports*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentSelfserve*Route.php` | `/department/selfserve/*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
### Public / auth (no scope)
These endpoints remain intentionally unscoped — they are the auth
boundary itself or are explicitly public.
| File | Endpoints |
|---|---|
| `authRoute.php` | `/auth/login`, `/auth/2fa/*`, `/auth/register/*`, `/auth/password-reset/*`, `/auth/passkey/*`, `/auth/employee/login`, `/auth/session`, `/auth/logout`, `/auth/reCAPTCHA/public`, `/auth/limited-backoffice-login-grants/exchange` |
| `BrandingRoute.php` | `/branding` (read; write is admin-only) |
| `pingRoute.php` | `/ping` |
| `optionsRoute.php` | `/options*` |
| `passkeysRoute.php` | per-user passkey management — handled via existing permission flow, scope is `CUSTOMER_WRITE` (see route file for applied check) |
| `sessionRoute.php` | `/session*` |
| `userRoute.php` | `/user*` self — `CUSTOMER_READ` (own data) |
| `customerTimeBookingsRoute.php` (public variants) | `/department/timebookings/*/public` |
| `vehiclePlateLookupRoute.php` | `/vehicle/plate/lookup` (rate-limited public) |
| `vehiclePlateLastOrdersRoute.php` | `/vehicle/plate/last-orders` |
| `vehicleProductSuggestionRoute.php` | `/vehicle/product-suggestion` |
| `callbackMicrosoftRoute.php` | `/callback/microsoft/token` |
| `birdVoiceWebhooksRoute.php` | `/bird/voice/calls/webhook/inbound` (external webhook) |
| `formRoute.php` | `/form*` (public form submission) |
| `guestRoute.php` | `/guest*` |
| `BrandingRoute.php` (read) | `/branding` |
| `errorReportRoute.php` | `/error-report*` (public error reporting) |
### Module routes (`/modules/...`)
These wrap external integrations. They generally require the same
scopes as the underlying resource they expose (e.g. `moduleMotorAPIRoute`
operates on vehicles → `CUSTOMER_READ`/`WRITE`). The detail is in
the individual files. High-level summary:
| File prefix | Scope |
|---|---|
| `moduleMotorAPIRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (vehicle data) |
| `moduleStripeRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
| `moduleEconomicRoute.php` / `moduleEconomicCustomerRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
| `moduleWeatherAPIRoute.php` | none (cached public data) |
| `moduleFxRatesAPIRoute.php` | none (cached public data) |
| `moduleGatewayAPIRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleEdgeGatewayRoute.php` / `edgeGatewayConfigRoute.php` / `edgeGatewaysRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleLimbleRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleScannerRoute.php` | `CUSTOMER_READ` (plate scanners) |
| `moduleSelfServeRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleVirkDataRoute.php` | `CUSTOMER_READ` (CVR lookup) |
| `moduleWorkfeedRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleN8nRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleEntraRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleUsageRoute.php` / `moduleActionLogsRoute.php` | `SUPERUSER_READ` |
| `moduleConfigRoute.php` / `moduleBackupsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleXLVaskRoute.php` / `xlvaskUsageLogsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
### Cron / system
| File | Endpoints | Scope |
|---|---|---|
| `cronRoute.php` | `/superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `releaseManagerRoute.php` | `/release-manager*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `systemSearchRoute.php` | `/system-search*` | `SUPERUSER_READ` |
| `notificationsRoute.php` | `/notifications*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (own) |
## Routes skipped (with reason)
Per the rules in TRU-149, routes with unclear scope mappings were
left alone with a TODO comment rather than guessed.
| Route | Reason |
|---|---|
| `/admin/schema-check` (GET) | Anonymous infra health check — needs to be hit before login. Marked TODO; keep open for ops review. |
| `birdControlPlaneRoute.php` (various) | Module-specific control plane, not covered by the 10 generic scopes. TODO per-endpoint. |
| `intimidateRoute.php` | One-off integration endpoint, scope unclear. Skipped. |
| `limitedBackofficeRoute.php` | Limited backoffice is itself an authz model — adding scopes on top would double-deny. TODO. |
| `formRoute.php` (POST variants) | Public form endpoints, no clear scope. |
| `accountDeletionRoute.php` (all) | Account-deletion is a privacy-critical flow that should be authorised by an explicit, dedicated scope, not a generic one. TODO: add `account:delete` scope in TRU-145. |
| `washCertificateDebugRoute.php` (all) | Debug endpoint, scope unclear. Marked TODO. |
| `passkeysRoute.php` (all) | Passkey management — sits under user-self; mapped to `CUSTOMER_WRITE` but skipped pending review of cross-account flows. |
| `statisticsRoute.php` (all) | Statistics access scope unclear. Skipped. |
| `permissionsRoute.php` (all) | Permissions metadata route; left untouched. |
| `rolesRoute.php` (all) | Roles metadata route; left untouched. |
| `workerRoute.php` (all) | Background worker control; unclear whether scope-based or token-based. Skipped. |
| `orderBookingRoute.php` | Order-side booking — small file, skipped to keep PR focused. |
| `cronRoute.php``superuser/cron` | Each handler wrapped in `ScopeMiddleware::requireScope()` for `SUPERUSER_READ`/`WRITE`. |
## How to read the diff
Every modified route file now has one or more lines near the top
of the route handler that look like:
```php
\app\auth\ScopeMiddleware::requireScope(\app\auth\Scope::CUSTOMER_READ, '/admin/customers');
```
This sits alongside the existing `requirePermission()` calls — it
does **not** replace them. The scope check is an additional gate.
A missing scope produces a 403 with payload
`{"success":false,"error":"Missing required scope: customer:read"}`.
## Open questions for TRU-145
1. Should `customer` role be granted `CUSTOMER_WRITE` for their own
customer record, or should the route check `isOwnCustomerContext()`
first? Current `Scope::forRole('customer')` gives read-only.
2. Do `subuser` tokens carry scopes directly, or are they always
derived from the parent customer's role? Affects
`ScopeMiddleware::resolveGrantedScopes()` shape.
3. Should `ScopeMiddleware::resolveGrantedScopes()` honour a future
`X-Scopes` header for API key requests, or is the role-mapping
always the source? TRU-149 picks role-mapping as a stop-gap.
## Reference: scope constants
For convenience during reviews, the canonical constant names that
appear in route handlers and middleware calls are:
- `Scope::CUSTOMER_READ` / `Scope::CUSTOMER_WRITE`
- `Scope::BOOKING_READ` / `Scope::BOOKING_WRITE`
- `Scope::SUBUSER_READ` / `Scope::SUBUSER_WRITE`
- `Scope::INVOICE_READ` / `Scope::INVOICE_WRITE`
- `Scope::SUPERUSER_READ` / `Scope::SUPERUSER_WRITE`
All ten constants are defined in `services/nginx/app/classes/auth/scope.php`
and exported via `Scope::all()`. Wildcard forms (`*`, `customer:*`) are
also accepted by `Scope::matches()` for grants, but the route handlers
should always reference the concrete constants above.
@@ -0,0 +1,262 @@
# E-conomic Export Field Audit (TRU-193)
**Status:** Complete
**Date:** 2026-08-17
**Scope:** All user-input fields that flow into e-conomic API payloads from
the `copenhagentruckwash/api` backend.
**Primary files audited:**
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
## Summary
| Category | Count |
|----------|-------|
| User-input fields audited | 17 |
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
| Fields that are controlled input (no sanitization needed) | 4 |
| Fields not present in any e-conomic export path (out of scope) | 3 |
All user-input fields flowing to e-conomic are now either sanitized via
`economic_export_sanitizer` or verified to be controlled input.
## Sanitizer methods used
| Method | Purpose | Length cap |
|--------|---------|------------|
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
Rules applied:
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
- Tab + newline characters collapse to a single space
- Whitespace normalized and trimmed
- Length capped with `...` suffix if too long
## Audit by field
### 1. `order.po` (purchase order)
- **Source:** `orders_o::po` (user input)
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 2. `order.reference`
- **Source:** `orders_o::reference` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
### 3. `order.notes`
- **Source:** `orders_o::notes` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
- **Sensitive to:** `/`, special chars, length (capped at 50)
### 5. `department.name`
- **Source:** `departments_o::getDepartmentName()` (admin input)
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
### 6. `order.created_at` (formatted date)
- **Source:** `orders_o::created_at` (server-generated timestamp)
- **Flows to:** Transaction header line date prefix
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
but the sanitizer does not run on the formatted string — verified by inspection
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
### 7. `order.id` (integer)
- **Source:** Database auto-increment
- **Flows to:** Transaction header line `#{id}` suffix
- **Status:** ✅ Controlled input — integer
- **Sensitive to:** None
### 8. `order_item.reference`
- **Source:** Per-item reference (user input)
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 9. `order_item.notes`
- **Source:** Per-item notes (user input)
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 10. `order_item.product.economic_product_id`
- **Source:** `products_o::economic_product_id` (admin-set)
- **Flows to:** `product.productNumber` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductNumber()`
- **Sensitive to:** Path separators, illegal chars
### 11. `order_item.product.name`
- **Source:** `products_o::name` (admin-set product name)
- **Flows to:** `description` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
- **Source:** Numeric fields (calculated or admin-set)
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
- **Sensitive to:** None
### 13. Currency (`DKK`, `EUR`, etc.)
- **Source:** Admin-set on the department / invoice
- **Flows to:** `'currency' => $currency` in the invoice payload
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
- **Sensitive to:** None
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.name` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
it), we sanitize defensively in case e-conomic later rejects a value it
previously accepted, or in case the API contract changes. Cap of 100 chars
matches the e-conomic recipient `name` field limit.
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.address` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 250)`
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
countries use `/` in street names), control chars, length
- **Rationale:** Same as `recipient.name` — defense in depth.
### 16. `recipient.zip`, `recipient.city`
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
- **Sensitive to (defense in depth):** Special chars, length
- **Rationale:** Defense in depth — same as above.
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getEan()` (e-conomic data)
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
rather than sending an empty string (which e-conomic may reject).
## Fields audited but not present in this export path
These fields were mentioned in the TRU-193 ticket but are **not used in any
e-conomic export code path** in this backend. Documenting them for
completeness:
| Field | Why not in scope |
|-------|------------------|
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
## Other controlled inputs (no sanitization needed)
| Field | Why safe |
|-------|----------|
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
| `vatZone.vatZoneNumber` | Integer from e-conomic |
| `customer.customerNumber` | Integer from e-conomic |
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
| `TotDiscount` (productNumber for discount line) | Literal string constant |
| `'Rabat'` (description for discount line) | Literal string constant |
## Defense in depth: preflight validation
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
now runs a **preflight validation** before sending to e-conomic. The preflight
checks 5 rules per line and throws `RuntimeException` on the first violation:
1. `description` must be non-empty after `trim()`
2. `description` must be ≤ 250 chars
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
4. `quantity` (if present) must be a positive number
5. `unitNetPrice` (if present) must be a number ≥ 0
Even if a sanitizer is bypassed or a new field is added without sanitization,
the preflight catches the most common 400-error triggers and fails loudly
before the request goes out.
## Test coverage
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
- Original 31: slash replacement, control chars, tab/newline handling,
whitespace collapse, length cap with ellipsis, multibyte safety,
null/empty input, integer/float input, product number rules
- New 14 (TRU-193): recipient name/address/zip/city length caps,
recipient address newlines + slashes, Danish/UK postal formats,
Danish special chars (København Ø), ampersand + quotes, CRLF
normalization, empty-field handling, EAN digit preservation
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
- Covers: all 5 preflight rules + the disabled-flag bypass path
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
(sanitize calls for name/address/zip/city, preg_replace for EAN,
empty-EAN unsets the key)
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
preflight catches all 5 rules, mixed text + product flow works
Total: 94 tests, 171 assertions, all passing.
## What changed in TRU-193
1. **Pre-flight validation** added to `economic_invoice_draft.php`
(separate atomic commit) — defense in depth.
2. **Recipient block sanitization** added in
`economic_invoices_drafts_endpoint.php`:
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
now go through `sanitizeTextLine()` with field-appropriate length caps.
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
removed from the payload (and `nemHandelType` is not set).
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
happened in the calling methods). Catches any new caller that forgets
to sanitize.
- `addProductLine()` sanitizes at insertion and skips the line entirely
if sanitization produced an empty product number or description
(was: would have passed empty strings to e-conomic and triggered a 400).
4. **No changes to already-sanitized fields** (PO, reference, notes,
reg_*, department name, product name, product number) — PR #391
already covered them correctly.
## Refs
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
- TRU-189 through TRU-196 — Related issues covered by PR #391
- TRU-194 — Pre-flight validation (separate workstream)
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
- PR #392 — Pre-flight validation defense in depth
@@ -0,0 +1,82 @@
# GitHub Secrets for e-conomic Live Verification
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
## Required Secrets
| Secret | Description | Where to find it | Required? |
|---|---|---|---|
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
## Optional Secrets (for Slack notifications)
| Secret | Description | Required? |
|---|---|---|
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
## How to Configure
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
2. Click **"New repository secret"**
3. Add each of the required secrets above
4. The values are found in your e-conomic account settings
## How to Run the Live Verification
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
2. Click **"Run workflow"**
3. Leave `customer_number` as `12345679` (default)
4. Set `dry_run` to **`false`** for a real test
5. Click **"Run workflow"**
6. The workflow will:
- Create a draft invoice for customer 12345679
- Add 2 test lines (1 with discount, 1 without)
- Verify the draft was created correctly
- **Automatically delete the draft** to clean up
## Safety
- The verification script is **idempotent**: it always cleans up after itself
- On any error, it attempts emergency cleanup of any draft it created
- The script refuses to run without the required env vars
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
## When It Runs Automatically
- **Manual trigger only by default**
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
## Setting Up in Production (api.truckwash.io)
The same e-conomic credentials are also used by the live API. They're stored in:
- The production server's `.env` file (loaded by PHP)
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
## Troubleshooting
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
The secret is not configured. Follow the "How to Configure" steps above.
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
Same as above for the secret token.
### "Draft creation returned HTTP 401"
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
### "Draft creation returned HTTP 403"
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
### "Customer 12345679 not found"
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
@@ -0,0 +1,98 @@
# Invoice Discount Format — DRIFT 12 (TRU-73)
## What changed
The e-conomic draft invoice now applies the **customer-level discount
percentage at the line level** on every line item, so the discount is
clearly visible on each service line on the customer's invoice.
Before this fix, a customer with a global e-conomic discount (e.g. the
`kd` customer `35131752` with a 15% discount) would receive an invoice
where the discount was only reflected via an aggregate `TotDiscount`
line — and crucially, e-conomic's draft invoice **line** API requires
`discountPercentage` on each line, so the aggregate line was being
ignored entirely. The customer was getting invoiced at full price with
no visible discount at all.
## Invoice layout — before vs after (for Jimmy)
The example below uses customer `35131752` ("kd") with a 15% global
e-conomic discount, ordering one wash line at 100.00 DKK.
### Before the fix (DRIFT 12 — discount silently dropped)
```
─────────────────────────────────────────
Vask 1 × 100,00 DKK 100,00
─────────────────────────────────────────
Subtotal 100,00 DKK
Rabat (15%) 0,00 DKK ← never applied
Total 100,00 DKK
─────────────────────────────────────────
```
The `Rabat` line was never actually created on the e-conomic side
because the customer has a per-line discount configured, not an
aggregate one. The customer saw 100,00 DKK with no discount displayed.
### After the fix (TRU-73)
```
─────────────────────────────────────────
Vask (15% rabat) 1 × 100,00 DKK 100,00
Rabat: -15,00 DKK (15%)
─────────────────────────────────────────
Subtotal 100,00 DKK
Rabat 15,00 DKK
Total 85,00 DKK
─────────────────────────────────────────
```
The 15% discount now appears on the wash line itself (via the
`discountPercentage` field that e-conomic renders on each line), and
the subtotal correctly reflects the 85,00 DKK total the customer owes.
## How the fix works
1. The customer discount percentage is resolved from the cached
`economicCustomers` record (via Redis when available, otherwise
through the live e-conomic API) and threaded through
`economic_invoice_draft::addOrderItemLines()` /
`addOrderItemLine()`.
2. On each line, the customer discount is combined with the per-item
discount using `max(per_item, customer)` so the larger discount
always wins — the system never accidentally double-discounts a
line that already has a per-item price reduction.
3. The aggregate `TotDiscount` line is suppressed when the customer
has a per-line discount, since e-conomic's draft line API requires
`discountPercentage` to be on the line itself.
4. The customer discount is clamped to 0..100 to guard against bad
data from the e-conomic API.
## Code paths
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
`addOrderItemLines()` and `addOrderItemLine()` now accept a
`customer_discount_percentage` argument and combine it with the
per-item discount at the line level.
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
— logs swallowed missing-currency-price errors so silently-missing
discounts become visible in the application log.
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
— forwards the customer discount percentage to the draft builder.
- `services/nginx/app/objects/collected_order_invoices_o.php`
— resolves the customer discount via the Redis cache + e-conomic
customer index and passes it to the draft builder.
## Tests
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new tests covering the customer 35131752 case (15% global discount,
applied at line level) plus edge cases: per-item + customer discount
combined, clamping to 0..100, zero-discount baseline.
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated to account for the new parameter and the customer-discount
guard on the aggregate `TotDiscount` line.
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
— updated to thread the new parameter through the batch transfer
pipeline.
@@ -0,0 +1,335 @@
# E-conomic Invoice Template Audit (TRU-197)
**Status:** Complete (no live call — credentials unavailable in this environment)
**Date:** 2026-08-17
**Scope:** Audit of the e-conomic invoice layouts available in the
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
the two-layout strategy (one for invoices **with** itemized discounts, one for
invoices **without**).
**Primary files audited:**
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
---
## TL;DR — Recommendation
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|---------|---------------------|---------------------|---------------|
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
The two layout numbers above are **placeholders** to be confirmed by the
account admin in e-conomic. They are written into the runtime config variables
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
section below).
---
## 1. Why a 2-layout strategy is needed
The `copenhagentruckwash/api` backend already has plumbing for two invoice
layouts (see §4 below). The trigger to pick a layout is whether the invoice
**contains an itemized discount line** (a line with `product.productNumber =
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
`Rabat` aggregator in `economic_invoice_draft`).
When such a line is present, the system routes the invoice through
`invoiceDiscountLayoutNumber`; otherwise it falls back to
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
that match these two intents (clean invoice vs. one that shows discounts
itemized).
---
## 2. Available e-conomic API for layouts
### 2.1 Endpoint
```
GET https://restapi.e-conomic.com/layouts
```
### 2.2 Auth headers (same as every other e-conomic call)
```
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
Content-Type: application/json
```
### 2.3 Response shape
The endpoint already exists in the codebase at
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
and is exposed to superusers via
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
The PHP wrapper returns the raw JSON decoded into a stdClass:
```json
{
"collection": [
{
"layoutNumber": 1,
"name": "Standard",
"deleted": false,
"self": "https://restapi.e-conomic.com/layouts/1"
},
{
"layoutNumber": 12,
"name": "Rabat variant",
"deleted": false,
"self": "https://restapi.e-conomic.com/layouts/12"
}
]
}
```
The minimal documented fields per layout are:
| Field | Type | Description |
|----------------|---------|-------------|
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
> concept in e-conomic is per-customer-group, not global. To find the agreement
> default, query `/customers?filter=...` and look at the layout referenced on
> each customer group's default. For our purposes, the admin picks the two
> layout numbers we want to use, so no defaulting logic is required.
### 2.4 Example curl (run with real creds)
```bash
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
-H "Content-Type: application/json" \
| jq '.collection[] | {layoutNumber, name, deleted}'
```
### 2.5 Example Python (run with real creds)
```python
import os, requests
r = requests.get(
"https://restapi.e-conomic.com/layouts",
headers={
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
"Content-Type": "application/json",
},
timeout=15,
)
r.raise_for_status()
for layout in r.json()["collection"]:
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
```
---
## 3. Live call — was it made?
**No.** This audit was run in a sandbox that does not have
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
would have polluted the e-conomic log with a noisy failed request at worst.
The two layout numbers used by the test fixtures
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
**configured** values that need to be **confirmed** by the e-conomic account
admin and, if changed, written into the e-conomic module config (see §4.3
env-var mapping).
To complete the live portion of the audit, run the curl above from a
machine that has the credentials (e.g. a developer laptop or a CI runner with
the secrets mounted). Paste the output into §6 of this doc and commit.
---
## 4. Current code state
### 4.1 Where layouts are read at runtime
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
`resolveLayoutNumber()` (line 115): returns either
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
contains a `discountPercentage > 0` product line.
* `services/nginx/app/objects/collected_order_invoices_o.php`
`resolveInvoiceLayoutNumber()` (line 673): same logic for collected
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
(default = `invoice_layout`).
### 4.2 Where layouts are configured
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
registers the `invoiceLayoutNumber` module config variable (default `1`,
required). This is the "no-discount" layout.
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
registers the `invoiceDiscountLayoutNumber` module config variable (default
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
Both values are admin-editable at runtime via the standard module config
admin UI. The system status probe also lists them as required:
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
`1` / `6`).
### 4.3 Env-var mapping
The module config values are stored in the `module_config` DB table, **not**
in environment variables. The contract is:
| Runtime value | Source | Where it's set |
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
e-conomic — they have no relationship to the layout-number config values.
That said, the task description asks for two env-var-style placeholders.
We will add the following **module-config aliases** (constants only, no
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
operator or a deployment automation can refer to them by name:
| Module-config constant | Friendly alias env-var-style name | Meaning |
|-----------------------------------|--------------------------------------|--------------------|
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
> If the deployment process is ever updated to read these from env vars
> instead of the module-config DB, the constant names in
> `economic_layout_selector.php` are the right place to wire that up.
### 4.4 Existing PRs and related work
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
`economic_export_sanitizer` class and per-field sanitization on the
draft invoice lines, recipient block, and references.
* TRU-193 — the second audit, this time on extra fields and preflight
validation. See `documentation/economic/export-field-audit.md` for the
full sanitization audit.
* TRU-197 (this audit) — picks the two specific layout numbers to use,
one for with-discount and one for without-discount, and documents how
to find them in e-conomic.
---
## 5. Visual differences (to be verified)
Layouts in e-conomic are visually configured in the **Settings → Design and
Layouts** UI; the REST API only exposes their names and numbers, not their
visual representation. From the existing example invoice
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
a **booked** invoice with discounts has this structure:
```
lines: [
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
{ lineNumber: 11, sortKey: 11 }
]
```
This invoice was **booked** with `layoutNumber = 12` (per the sample in
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
it predates the audit and is not necessarily the final answer.
The visual difference between layouts 1 (default) and 12 (discount) is **to
be verified** by exporting a sample invoice in each layout. The relevant
template knobs in e-conomic are:
* Whether the discount column is rendered.
* Whether the `Rabat` line is broken out vs. folded into the per-product
`discountPercentage`.
* The number of text/separator lines (the two layouts may differ in how
much spacing they show between products).
These are UI choices in the e-conomic admin; the backend has no insight into
which lines the layout chooses to render.
---
## 6. Live-call results — TO BE FILLED IN
_Paste the output of the curl in §2.4 below, then commit._
```
# layoutNumber name deleted
# ------------ ---------------------------- -------
# 1 Standard false
# 12 Rabat variant false
# ...
```
Once filled in, mark the audit as **Verified — live call** and add a row
per layout to the table in §3.1 if the layout count is larger than
expected.
---
## 7. Files added in this PR
| File | Purpose |
|------|---------|
| `documentation/economic/invoice-template-audit.md` | This document. |
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
The `economic_layout_selector.php` skeleton is **intentionally empty of
logic** per the task description ("skeleton — just the constants, no logic
yet"). Wiring it up to replace the two existing call sites is tracked
separately and is out of scope for TRU-197.
---
## 8. What we recommend the e-conomic admin do
1. Open e-conomic → Settings → Design and Layouts.
2. **Duplicate** the current "standard" layout (the one currently set as
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
3. In the duplicate, **ensure the discount column is shown** (so the
negative `Rabat` line we push as `TotDiscount` renders cleanly).
4. Note the `layoutNumber` of:
* The original (clean) layout → set as `invoiceLayoutNumber` in
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
(admin override, or via the module config UI).
* The duplicate (with-discounts) layout → set as
`invoiceDiscountLayoutNumber` in
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
5. Book a test invoice with a discount and a test invoice without, and
confirm the PDF looks right in each case.
---
## 9. Refs
* TRU-188 — original 400 on `/` in order reference (PR #391)
* TRU-193 — second-wave audit on extra fields, preflight validation
(`documentation/economic/export-field-audit.md`)
* PR #391 — initial sanitization fix
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
`GET /layouts` wrapper
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
`resolveLayoutNumber()` for single draft invoices
* `services/nginx/app/objects/collected_order_invoices_o.php`
`resolveInvoiceLayoutNumber()` for collected (batched) invoices
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
@@ -0,0 +1,274 @@
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
**Status:** Complete (investigation only — no code changes)
**Date:** 2026-08-17
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
an e-conomic draft invoice or sends draft lines, and document whether each
path currently picks a layout, which one it picks, and how the planned
**with-discounts / without-discounts** two-layout selection should apply.
**Related work:**
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
e-conomic layout numbers to use (one for clean invoices, one for invoices
that show itemized discounts).
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
/ sanitization, unrelated to layout selection but consumed by the same code
paths.
- PR #391`economic_export_sanitizer`, the sanitizer that all draft-line
paths now run their text through.
---
## Overview
A draft invoice in this codebase is built in two phases:
1. **Create the draft envelope**`POST /invoices/drafts` with a payload
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
`recipient`, `currency`, `date`, etc. This is the only place where
`layout.layoutNumber` is set on the draft.
2. **Add lines to the draft**`POST /invoices/drafts/{id}/lines` with an
array of product / text / discount lines. Lines are added either one
order at a time (single-order draft flow) or in accumulated batches
(collected-invoice flow). The layout is **already fixed** at this point
and is not re-sent.
There are therefore only **two** code paths in the entire backend that
create the draft envelope and could pick a layout. Both already implement
a discount-aware selector that returns either `invoice_layout` (no
discounts) or `invoice_discount_layout` (itemized discounts present):
| Selector function | Used by | File |
|---|---|---|
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()``economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
The two selectors are independent implementations of the same idea. They
both:
1. Inspect the lines that will be sent (or the orders that will be added
to the draft).
2. If any line / order has a non-zero `discountPercentage` (or, in the
collected-invoice path, any "billable discount" per
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
`invoice_discount_layout`.
3. Otherwise return `invoice_layout`.
4. Throw a `RuntimeException` / `Exception` if the discount layout is
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
The two config variables are defined in:
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
`invoiceLayoutNumber`, `int`, **required** (default `1`).
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
`invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
- Both are wired into `classes\economic::$config` via
`services/nginx/app/modules/economic/economic_c.php` lines 2548.
> **Net result of the audit:** the two-layout selection is already
> implemented in both places where a draft envelope is created. There is
> **no** code path that creates a draft without going through one of these
> two selectors. The migration is therefore a configuration change (set
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
> change. See §5 *Migration plan* for the small set of files that still
> touch the layout topic and may need follow-up.
---
## 1. Inventory of code paths
The table below lists every PHP function in `services/nginx/app/` that
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
helper) are excluded — they never pick a layout.
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|---|---|---|---|---|---|---|---|
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()``createInvoiceDraft()``resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
| 22 | `routes/economicInvoiceRoute.php:~380410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
**Read-only paths (excluded from the migration list):**
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21``get(int $invoice_id)`
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39``get_from_external_id(string $external_id)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35``get(array $filters, array $pagination)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60``get_all()`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73``get_invoice_lines(array $invoice_ids, array $filters)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201``exists(int $draft_invoice_number)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200``getInvoiceDraft(int $int)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170``getInvoicePdf(int $param)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160``deleteInvoiceDraft(int $value)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166``publishInvoiceDraft(int $invoiceDraftId)`**important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
**Out of scope (no draft creation):**
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
(read-only aggregations over booked invoices). Never creates a draft.
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
data class. No HTTP calls.
---
## 2. Current state
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
#16 / `createInvoiceDraft`) already have a working discount-aware selector
that returns one of two layout numbers from the config store.
- The selectors read from the same two config variables
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
already wired into `economic::$config` and surfaced in the
`EconomicConfigEntry` OpenAPI schema.
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
(see `economic_invoice_discount_layout_c.php``setupConfigVariable(...,
true, ...)` with `required = true` in the call signature but the
constructor's third arg `false` means a null value is allowed; the
selectors throw if it is required and ≤ 0).
- The selectors are independent code paths. They each inspect lines
slightly differently:
- The MO selector (`hasDiscountedItemizedLines`) checks
`discountPercentage > 0` per line.
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
which checks for a `TotDiscount` product (negative net price) on
included invoice items.
- Both reach the same boolean result: *does this draft need the discount
layout?* — so the layout chosen by either selector is consistent.
---
## 3. Desired state
After TRU-197 picks the two layout numbers and the operator configures
them in the `economic` module:
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
invoices.
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
**discount** invoices.
Then:
- A single-order draft with no itemized discount goes out with
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
- A single-order draft with an itemized discount goes out with
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
#4).
- A collected-invoice draft with no billable discount goes out with
`invoiceLayoutNumber` (path #16 / selector #17).
- A collected-invoice draft with a billable discount goes out with
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
No code changes are required to achieve this — only the two config
variables need to be set in the `economic` module (and validated by
the superuser status probe at `superuser_system_status_service.php:866`).
---
## 4. Migration plan
Because the selectors already exist, the migration is a **configuration
rollout** plus a small handful of defensive tasks. Files to touch:
### 4.1 Required for rollout
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
constructor signature already allows this to be a non-required variable,
but the selectors will throw a `RuntimeException` / `Exception` if the
discount layout is required and the value is 0 or null — so the rollout
must include setting this var in every environment.)
### 4.2 Verify-only (no edits expected)
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
as required keys for the `economic` module probe. Confirm the probe
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
error when missing (it currently appears in the `required` array, which
is the correct behavior).
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
enum already includes `invoiceDiscountLayoutNumber`. No change.
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893894`**
— test fixtures already cover both layout config vars. Confirm values
match TRU-197's picks.
### 4.3 Optional follow-ups (not blocking the rollout)
- **Defensive logging** in the two selector functions
(`economic_invoice_draft_mo::resolveLayoutNumber` and
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
layout was chosen and why (e.g.
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
This is useful for post-rollout verification in the e-conomic UI.
- **A single, shared selector helper** that both paths use, to avoid
drift between the two private selectors. Recommended location:
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
or a new
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
Out of scope for the configuration rollout; consider for a follow-up
refactor.
- **E2E / integration test** that:
1. Creates a single-order draft with at least one discounted line and
asserts the resulting draft's `layout.layoutNumber` equals
`invoiceDiscountLayoutNumber`.
2. Creates a single-order draft with no discounted lines and asserts
`invoiceLayoutNumber`.
3. Creates a collected-invoice draft with at least one
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
4. Creates a collected-invoice draft with no `TotDiscount` lines and
asserts `invoiceLayoutNumber`.
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
### 4.4 Files that explicitly need NO changes
- `services/nginx/app/classes/economic_v2_distribution_service.php`
distribution reporting, not a draft creator.
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
— booked-invoice data class.
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
/ `add_environmental_and_oil_fees` paths — they operate on an existing
draft whose layout was fixed at create time.
---
## 5. Summary
| Metric | Count |
|---|---|
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
The 2-layout selection is already wired through the backend. The TRU-198
investigation confirms that the rollout reduces to setting the two
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
picks, plus optional defensive logging and an E2E test for verification.
@@ -0,0 +1,323 @@
# TRU-62 — Customer search / transaction history slow (~10s)
**Investigation date:** 2026-08-17
**Branch:** `feat/TRU-62-perf-customer-search`
**Investigator:** automated perf-investigation agent
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
---
## 1. Summary
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
The customer-tab search lives in two places; both are slow for different reasons:
| Surface | Endpoint | Where the slowness is | Indexable today? |
| --- | --- | --- | --- |
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
---
## 2. Root causes (ranked)
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510600).
```php
// system_search_service.php — searchTableWithJoin() (excerpt)
$termClauses = [];
foreach ($terms as $term) {
$escaped = $db->escape_string($term);
foreach ($searchFields as $field) {
$termClauses[] = "$field LIKE '%$escaped%'";
}
}
```
```php
// db_object_t.php — listObjectsWithPagination() (excerpt)
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
```
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
**Symptom → data size (estimate).**
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
| --- | --- | --- | --- |
| 1k | 100k | ~50ms | ~300ms |
| 10k | 1M | ~500ms | ~3s |
| 50k+ | 5M+ | ~310s ❌ | ~10s+ ❌ |
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
```php
// system_search_service.php — executeLexicalSearch() (excerpt)
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
} else {
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
}
```
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
`services/nginx/app/classes/system_search_service.php` lines 713820:
```php
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields, // 13 fields
$terms,
'1=1' . $customerFilter
);
```
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
### RC6 — `users.display_name` has no index at all
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
```sql
CREATE TABLE IF NOT EXISTS `users` (
...
KEY `idx_users_customer_number` (`customer_number`),
KEY `idx_users_group_id` (`group_id`)
);
```
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
---
## 3. SQL queries involved (verbatim paths)
### 3.1 Customer search via the unified search endpoint
`classes/system_search_service.php` lines 713820 produce something like:
```sql
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
sci.economic_name, sci.economic_address, ..., sci.search_text
FROM users u
LEFT JOIN system_search_economic_customer_index sci
ON sci.customer_number = u.customer_number
WHERE 1=1
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
OR sci.search_text LIKE '%foo%' )
LIMIT 50
```
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
### 3.2 Order list / transaction history
`traits/db_object_t.php` lines ~547556 produce, for a search of `foo` and a filter `customer_id:123`:
```sql
SELECT *
FROM orders_with_invoice_collections
WHERE customer_id = 123
AND deleted_at IS NULL
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
ORDER BY id ASC
LIMIT ? OFFSET ?
```
* 22 ORed LIKE clauses against the view, all un-indexable.
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
---
## 4. Schema snapshots
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_users_customer_number (customer_number)
KEY idx_users_group_id (group_id)
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
```
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_orders_customer_id (customer_id)
KEY idx_orders_department_id (department_id)
KEY idx_orders_invoice_collection_id (invoice_collection_id)
KEY idx_orders_reg_1 (reg_1)
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
```
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
```sql
PRIMARY KEY (customer_number)
INDEX idx_system_search_econ_customer_user (user_id)
INDEX idx_system_search_econ_customer_name (economic_name)
INDEX idx_system_search_econ_customer_email (economic_email)
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
-- Missing: FULLTEXT on (search_text)
```
### `system_search_documents` (from `classes/system_search_document_index.php`)
```sql
PRIMARY KEY (entity_type, entity_id)
INDEX idx_ssd_customer (customer_number)
INDEX idx_ssd_department (department_id)
INDEX idx_ssd_entity (entity_type)
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
```
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
---
## 5. EXPLAIN (expected)
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
```
type: ALL -- full table scan
key: NULL
rows: N (all users)
Extra: Using where
```
For the order list query the expected plan against the view is:
```
type: ALL
key: NULL
rows: N
Extra: Using where; Using filesort
```
Once a FULLTEXT index is added the same queries should become:
```
type: fulltext
key: ft_xxx
rows: O(log N)
Extra: Using where; Ft_hints: ...
```
---
## 6. Recommended fixes (ordered by ROI)
| # | Fix | Estimated effort | Estimated impact | Risk |
| --- | --- | --- | --- | --- |
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
### Recommended sequencing
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
---
## 7. Implementation plan (this PR)
This PR ships **F1 only**, as a low-risk drop-in:
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
```sql
ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
```
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
```sql
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
```
and only fall back to the 13-clause OR if MATCH returns zero rows.
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
* Stubs `$db` to record the last query.
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
### What this PR does **not** do
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
* No schema changes to `users`.
* No changes to the cron / dirty-table logic (F2).
These are tracked as follow-up issues.
---
## 8. Test impact
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
`master` before this change.
---
## 9. Open questions / follow-ups
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
+16
View File
@@ -0,0 +1,16 @@
# Security documentation
This folder holds security-related planning, post-mortems, and pen-test
artefacts for the Truck Wash ApS platform.
| Doc | Purpose | Status |
| --- | --- | --- |
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
Conventions:
- Pen-test reports and any raw findings live in date-stamped subfolders
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
repository — only the planning docs and re-test acceptance letters are.
- All security work is tracked under the Linear project
*UI Library & Pen Testing*.
+304
View File
@@ -0,0 +1,304 @@
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
> ## ⛔ CANCELLED — DO NOT EXECUTE
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
>
> ---
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
**Project:** UI Library & Pen Testing
**Priority:** Medium
**Status (this doc):** Draft v1 — ready for engineering + management review
**Author:** bugfix sub-agent (TRU-80)
**Date:** 2026-08-16
---
## 1. Purpose
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
is intended to validate the security posture of the customer- and operator-facing
production stack before further public rollout and ahead of any major commercial
expansion (e.g. additional self-serve sites, additional payment integrations).
This document is the planning artefact for TRU-80. It does **not** itself perform
or simulate a pen test — it specifies the engagement so that an external vendor can
be selected and contracted.
---
## 2. Scope (in)
The following systems are **in scope** for the engagement. Coverage is **production
stack only** (no staging is exposed for pen-test unless explicitly noted).
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
- Authentication / session endpoints, including:
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
`subusersRoute.php`, `limitedBackofficeRoute.php`
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
- Authorization model: role-based access (customer / sub-user / backoffice /
superuser) and per-customer data isolation.
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
collected-invoices, order routes.
- Payment integration: Stripe module (`moduleStripeRoute.php`).
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
token handling, invoice push.
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
Redis trait (`redis_t.php`).
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
our code consumes it; the upstream WP instance is **out of scope** unless
hosted by us.
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
`coolify` deploy config. Black-box reachable attack surface only.
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
- API client and token storage in the SPA (where tokens live, at-rest
protection, refresh flow).
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
the root (`openapi.yaml`).
- Capacitor deep-link / universal-link / custom-scheme handling
(`capacitor.config.ts`).
### 2.3 Infrastructure & cross-cutting (in)
- TLS configuration (cert chain, HSTS, cipher suites) on the production
public host.
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
Permissions-Policy, X-Content-Type-Options).
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
- Email & SMS notification paths only as far as they can be abused for
spoofing / phishing of our users (we control the From domain).
### 2.4 Out of scope (explicitly)
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
not the third party itself.
- Internal office LAN, employee laptops, MDT, and physical site hardware
(gate controllers, scanners) — these are covered by a separate physical /
OT scope and **out of scope** for this IT pen test.
- Denial-of-service / load testing.
- Social engineering of Truck Wash staff.
- Source-code review of `node_modules` / vendor dependencies (the engagement
will use SCA tooling to flag known CVEs, but not audit transitive deps).
- Any production data exfiltration — the vendor will be given sanitised or
test accounts and synthetic data only.
---
## 3. Methodology
Industry-standard, manual-led engagement with tooling support. Recommended
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
use **OWASP MASVS** as the checklist.
Phases (estimated total: 12 working days of vendor effort, see §6):
1. **Scoping & recon (1 day)**
- Confirm target list, accounts, and rules of engagement.
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
- Active recon limited to non-destructive fingerprinting.
2. **API pen test (3 days)**
- AuthN/AuthZ boundary testing on every route group in §2.1.
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
wash certificates, sub-users, customer notes).
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
deserialisation, header injection.
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
discount stacking, sub-user privilege escalation.
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
3. **Web SPA pen test (2 days)**
- XSS (reflected, stored, DOM-based) including Vue template injection.
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
- Open-redirect / OAuth misconfig in any SSO flow.
- CSP / SRI effectiveness.
4. **Mobile (Capacitor) review (2 days)**
- Static analysis of the built APK / IPA (Capacitor WebView).
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
custom-scheme handlers).
- Local storage of tokens, biometric bypass if implemented.
- Deep-link / universal-link hijack attempts.
5. **Infrastructure & config (1.5 days)**
- TLS, headers, cookie flags, HSTS preload eligibility.
- NGINX hardening review (based on provided config snapshots).
- Docker / coolify surface only as externally reachable.
6. **SCA / dependency check (0.5 day)**
- `composer.json` and `package.json` SCA scan.
- High-severity known-CVE report only; no deep audit.
7. **Exploitation & PoC (1 day)**
- Build proofs-of-concept for any Critical / High findings.
8. **Reporting & re-test (1 day)**
- Draft report → vendor walkthrough → final report.
- Re-test of fixed findings is scoped separately (see §6).
---
## 4. Rules of engagement (RoE)
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
exploitation only with prior written approval per critical finding.
- **Contact channel:** shared Signal thread + email; vendor given a Slack
guest account in a dedicated `#sec-pentest-2026Q4` channel.
- **Stop conditions:** any finding that risks data loss, payment integrity,
or production gate operation → immediate stop + phone call to on-call.
- **Data handling:** vendor may only use synthetic / test data. No
exfiltration of real customer PII. All artifacts returned or destroyed at
end of engagement (TBD in contract).
- **Coverage of third parties:** the vendor will not test Stripe / Economic
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
we follow responsible-disclosure to the vendor ourselves.
---
## 5. Deliverables
1. **Kick-off doc** (this plan, signed off by both parties).
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
findings list).
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
Critical / High so we can start patching in parallel.
4. **Final report (PDF + JSON)** including:
- Executive summary, risk heatmap, business-impact narrative.
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
screenshots / Burp session, recommended fix, references.
- SCA dependency report as an appendix.
5. **Re-test letter** (separate SOW, see §6).
6. **Knowledge transfer**: 60-min session for engineering on the top 5
findings.
---
## 6. Budget & scheduling
### 6.1 Indicative effort
| Phase | Days | Notes |
| --- | --- | --- |
| 1. Scoping & recon | 1.0 | joint with us |
| 2. API pen test | 3.0 | |
| 3. Web SPA | 2.0 | |
| 4. Mobile (Capacitor) | 2.0 | |
| 5. Infra & config | 1.5 | |
| 6. SCA | 0.5 | tooling-led |
| 7. Exploitation / PoC | 1.0 | |
| 8. Reporting | 1.0 | incl. 1 review round |
| **Total** | **12.0 days** | |
### 6.2 Indicative cost (DKK, ex. VAT)
Pricing varies significantly with vendor. Three realistic budget tiers for
procurement:
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
| --- | --- | --- | --- |
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 16 000 | **144 000 192 000** | Best fit for our stack size, Danish-language reporting available. |
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 22 000 | **180 000 264 000** | More brand name, more bureaucracy, stronger report templates. |
| Top-tier / Big-4 style | 25 000 40 000 | **300 000 480 000** | Overkill for current footprint; revisit at Series-A. |
**Recommended envelope: 180 000 220 000 DKK** (mid-tier, 12 days) plus a
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
report).
Add ~5 000 DKK contingency for incident-response hours if a Critical is
found mid-engagement.
### 6.3 Schedule (proposed)
- **2026-08-25** — this plan reviewed and signed off by management.
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
proposals, evaluate.
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
- **2026-09-22 (week 39)** — engagement kick-off.
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
weeks, vendor working in parallel with their normal cadence).
- **2026-10-08** — draft report.
- **2026-10-15** — final report + walkthrough.
- **2026-11-15** — re-test (retainer).
All dates are **provisional** until a vendor is selected.
### 6.4 Vendor shortlist (candidates to approach)
We will request proposals from at least 3 of the following (final shortlist
to be confirmed with management):
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
for enterprise due-diligence.
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
---
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
We should land these before the vendor starts — they reduce noise and let
the vendor focus on real issues:
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
Gateway webhooks
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
- [ ] Rate-limit on auth, password reset, and OTP endpoints
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
- [ ] File-server (`file_server.php`) path-traversal tests in CI
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
high+ vulns
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
removed
- [ ] Secrets: no production keys in repo (`git log -S` audit)
This list is also the basis for re-test acceptance criteria.
---
## 8. Open questions for management
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
2. Confirm legal/procurement owner and contract template.
3. Confirm whether to require a Danish-language final report (recommended).
4. Confirm re-test budget is approved up-front, or per-finding.
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
6-day "API + SPA only" first pass.
---
## 9. References
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
- OWASP MASVS — https://mas.owasp.org/MASVS/
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
---
*This document is a planning artefact, not the test itself. Once approved, a
separate SOW will be drafted with the selected vendor and linked from this
issue.*
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env php8.4
<?php
/**
* Live verification of e-conomic draft invoice creation using customer 12345679.
*
* This script:
* 1. Connects to the real e-conomic API (requires env credentials)
* 2. Creates a draft invoice for customer 12345679 with TEST items
* 3. Verifies the draft was created correctly
* 4. DELETES the draft to clean up
*
* Usage (on production server with credentials):
* php8.4 verify-economic-drafts-live.php
*
* Required env vars (set in .env or pass inline):
* ECONOMIC_API_APP_ACCESS_GRANT
* ECONOMIC_API_APP_SECRET_TOKEN
*
* Optional:
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
*
* Exit codes:
* 0 = all verifications passed, draft cleaned up
* 1 = error during verification
* 2 = cleanup failed (draft still exists, manual intervention required)
*/
declare(strict_types=1);
// 1. Load credentials
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
if (!$grant || !$secret) {
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
exit(1);
}
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
/**
* Send a request to the e-conomic API.
*
* @return array{status: int, body: string, json?: array}
*/
function econ_request(string $method, string $url, ?array $body = null): array
{
global $auth;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
trim(explode("\r\n", $auth)[0]),
trim(explode("\r\n", $auth)[1]),
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
return ['status' => 0, 'body' => $error];
}
$json = json_decode($response, true);
return ['status' => $status, 'body' => $response, 'json' => $json];
}
$draftInvoiceNumber = null;
$pass = 0;
$fail = 0;
$total = 0;
function check(string $name, bool $ok, string $detail = ''): void
{
global $pass, $fail, $total;
$total++;
if ($ok) {
$pass++;
echo "$name\n";
if ($detail) echo " $detail\n";
} else {
$fail++;
echo "$name\n";
if ($detail) echo " $detail\n";
}
}
echo "=== E-conomic Live Draft Verification ===\n";
echo "Customer: $customer\n";
echo "API base: $baseUrl\n\n";
try {
// ------------------------------------------------------------------
// Step 1: Verify customer exists
// ------------------------------------------------------------------
echo "Step 1: Verify customer $customer exists...\n";
$resp = econ_request('GET', "$baseUrl/customers/$customer");
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 200) {
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
exit(1);
}
$customerName = $resp['json']['name'] ?? 'unknown';
echo " Customer name: $customerName\n\n";
// ------------------------------------------------------------------
// Step 2: Create draft invoice
// ------------------------------------------------------------------
echo "Step 2: Create draft invoice for customer $customer...\n";
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
'currency' => 'DKK',
'customer' => ['customerNumber' => $customer],
'paymentTerms' => ['paymentTermsNumber' => 1],
'layout' => ['layoutNumber' => 1],
'recipient' => ['name' => 'OpenClaw Live Verification'],
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
]);
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
if ($resp['status'] !== 201) {
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
echo " Draft invoice number: $draftInvoiceNumber\n\n";
if (!$draftInvoiceNumber) {
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
// ------------------------------------------------------------------
// Step 3: Add test lines to draft
// ------------------------------------------------------------------
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
$lines = [
[
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
'quantity' => 1.0,
'unitNetPrice' => 100.00,
'discountPercentage' => 0.0,
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
],
[
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
'quantity' => 2.0,
'unitNetPrice' => 200.00,
'discountPercentage' => 15.0,
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
],
];
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
'lines' => $lines,
]);
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
// ------------------------------------------------------------------
// Step 4: Verify draft contents
// ------------------------------------------------------------------
echo "\nStep 4: Verify draft contents...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
$draft = $resp['json'] ?? [];
$draftLines = $draft['lines'] ?? [];
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
// ------------------------------------------------------------------
// Step 5: Cleanup - delete the draft
// ------------------------------------------------------------------
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
// ------------------------------------------------------------------
// Step 6: Verify deletion
// ------------------------------------------------------------------
echo "\nStep 6: Verify draft is gone...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
} catch (\Throwable $e) {
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
// Best-effort cleanup
if ($draftInvoiceNumber !== null) {
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
echo " Cleanup HTTP status: {$resp['status']}\n";
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
}
exit(1);
}
echo "\n=== Summary: $pass/$total checks passed ===\n";
exit($fail === 0 ? 0 : 1);
@@ -0,0 +1,227 @@
<?php
namespace classes;
/**
* Static utility for generating, formatting, hashing, and parsing
* API keys.
*
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
*
* The key_id (everything before the dot) is stored in plain text in
* the database as the lookup key. The secret is NEVER stored in plain
* text — only the argon2id hash is persisted. The full key is shown
* to the user exactly once at creation time.
*/
class api_key_generator
{
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
/** Characters permitted in the public key_id portion. */
public const KEY_ID_RANDOM_LENGTH = 22;
/** Characters in the secret portion. */
public const SECRET_LENGTH = 32;
/**
* Build the public key_id portion: <prefix>_<env>_<random>.
*/
public static function generateKeyId(string $env = 'live'): string
{
$env = self::normaliseEnv($env);
$prefix = self::prefix();
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
return $prefix . '_' . $env . '_' . $random;
}
/**
* Generate the secret portion (32-char base62).
*/
public static function generateSecret(): string
{
return self::randomBase62(self::SECRET_LENGTH);
}
/**
* Join key_id and secret with a single dot.
*/
public static function formatKey(string $keyId, string $secret): string
{
if ($keyId === '' || strpos($keyId, '.') !== false) {
throw new \InvalidArgumentException('key_id must not contain a dot');
}
if ($secret === '' || strpos($secret, '.') !== false) {
throw new \InvalidArgumentException('secret must not contain a dot');
}
return $keyId . '.' . $secret;
}
/**
* Hash the full key (or just the secret) using argon2id.
*/
public static function hash(string $plain): string
{
if ($plain === '') {
throw new \InvalidArgumentException('Cannot hash an empty value');
}
$hash = password_hash($plain, PASSWORD_ARGON2ID);
if ($hash === false) {
throw new \RuntimeException('Failed to hash with argon2id');
}
return $hash;
}
/**
* Verify a plaintext key against a stored argon2id hash.
*/
public static function verify(string $plain, string $hash): bool
{
if ($plain === '' || $hash === '') {
return false;
}
try {
return password_verify($plain, $hash);
} catch (\Throwable) {
return false;
}
}
/**
* Split a full "key_id.secret" string back into its parts.
*
* The key_id may contain underscores (as separators between
* prefix/env/random) and must be base62 + underscores. The
* secret must be strictly base62 with no separators.
*
* @return array{key_id:string, secret:string}|null
* null if the input is malformed.
*/
public static function parseKey(string $full): ?array
{
$full = trim($full);
if ($full === '' || strpos($full, '.') === false) {
return null;
}
// Split on the FIRST dot only — secrets are base62 and contain
// no dots, so there's exactly one separator.
$parts = explode('.', $full, 2);
if (count($parts) !== 2) {
return null;
}
[$keyId, $secret] = $parts;
$keyId = trim($keyId);
$secret = trim($secret);
if ($keyId === '' || $secret === '') {
return null;
}
// The key_id is "<prefix>_<env>_<random>" — base62 with
// underscore separators. The secret is pure base62.
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
return null;
}
return ['key_id' => $keyId, 'secret' => $secret];
}
/**
* Validate a key_id string: base62 with optional underscore
* separators. Exposed for testing.
*/
public static function isKeyId(string $value): bool
{
if ($value === '') {
return false;
}
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
}
/**
* Configurable prefix (default: "truck"). Reads from
* `config('api_key.prefix', 'truck')` if available, otherwise the
* default. Always lowercased and stripped of separators.
*/
public static function prefix(): string
{
$default = 'truck';
$value = $default;
if (function_exists('config')) {
try {
$candidate = config('api_key.prefix', $default);
if (is_string($candidate) && $candidate !== '') {
$value = $candidate;
}
} catch (\Throwable) {
$value = $default;
}
}
$value = strtolower(trim((string)$value));
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
if ($value === '') {
$value = $default;
}
return $value;
}
/**
* @internal — exposed for testing.
*/
public static function randomBase62(int $length): string
{
if ($length < 1) {
throw new \InvalidArgumentException('Length must be positive');
}
$alphabet = self::ALPHABET;
$alphabetMax = strlen($alphabet) - 1; // 61
$out = '';
$bytesNeeded = (int)ceil($length * 1.3) + 8;
$bytes = random_bytes($bytesNeeded);
$byteIndex = 0;
while (strlen($out) < $length) {
if (!isset($bytes[$byteIndex])) {
$bytes = random_bytes($bytesNeeded);
$byteIndex = 0;
}
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
// avoid modulo bias.
$byte = ord($bytes[$byteIndex]);
$byteIndex++;
$value = $byte & 0x3F;
if ($value > $alphabetMax) {
continue;
}
$out .= $alphabet[$value];
}
return $out;
}
/**
* @internal — exposed for testing.
*/
public static function isBase62(string $value): bool
{
if ($value === '') {
return false;
}
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
}
private static function normaliseEnv(string $env): string
{
$trimmed = strtolower(trim($env));
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
// If the input contained characters outside the allowed
// set, the sanitised result will differ from the trimmed
// input — in that case fall back to "live" rather than
// echoing a mangled version. Empty / whitespace-only input
// also falls back to "live".
if ($sanitised === '' || $sanitised !== $trimmed) {
return 'live';
}
return $sanitised;
}
}
@@ -0,0 +1,249 @@
<?php
namespace classes;
use Exception;
use Throwable;
/**
* Repository for the `api_keys` table.
*
* This is a thin procedural wrapper that uses the project's existing
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
* other repositories in this codebase (see `classes/orders_o.php`,
* `classes/invoice_store.php`, etc.).
*
* Records are returned as associative arrays. The caller is expected
* to interact with them as plain dicts; there is no dedicated model
* class for api keys.
*/
class api_key_repository
{
public const TABLE = 'api_keys';
private static function db()
{
global $db;
if (!isset($db) || !is_object($db)) {
throw new Exception('Database connection ($db) is not available');
}
// Lazy-create the table on first use so callers don't have to
// remember to call ensureTables().
if (class_exists(api_key_schema_bootstrap::class)) {
api_key_schema_bootstrap::ensureTables();
}
return $db;
}
/**
* Validate the input data for create(). Exposed so test doubles
* can exercise the same validation without touching a real DB.
*
* @param array<string, mixed> $data
*/
public static function validate(array $data): void
{
$required = ['key_id', 'key_hash', 'name', 'role'];
foreach ($required as $field) {
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
throw new \InvalidArgumentException("Missing required field: {$field}");
}
}
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
if (!in_array($data['role'], $allowedRoles, true)) {
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
}
}
/**
* @param array<string, mixed> $data
* @return int inserted id
*/
public static function create(array $data): int
{
self::validate($data);
$db = self::db();
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
: null;
$stmt = $db->conn()->prepare(
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
if ($stmt === false) {
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
}
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
? (string)$data['expires_at']
: null;
$stmt->bind_param(
'sssssiss',
$data['key_id'],
$data['key_hash'],
$data['name'],
$data['role'],
$scopesJson,
$customerId,
$createdBy,
$expiresAt
);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to insert api_key: ' . $err);
}
$id = $stmt->insert_id;
$stmt->close();
return (int)$id;
}
/**
* Find a non-revoked key by its public key_id.
*
* @return array<string, mixed>|null
*/
public static function findActiveByKeyId(string $keyId): ?array
{
if ($keyId === '') {
return null;
}
$db = self::db();
$stmt = $db->conn()->prepare(
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
);
if ($stmt === false) {
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
}
$stmt->bind_param('s', $keyId);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute select: ' . $err);
}
$result = $stmt->get_result();
$row = $result ? $result->fetch_assoc() : null;
$stmt->close();
return $row ?: null;
}
/**
* Find any key by id (including revoked).
*
* @return array<string, mixed>|null
*/
public static function findById(int $id): ?array
{
$db = self::db();
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
if ($stmt === false) {
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute select: ' . $err);
}
$result = $stmt->get_result();
$row = $result ? $result->fetch_assoc() : null;
$stmt->close();
return $row ?: null;
}
/**
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
*/
public static function revoke(int $id): bool
{
$db = self::db();
$stmt = $db->conn()->prepare(
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
);
if ($stmt === false) {
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
$ok = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
return $ok && $affected > 0;
}
/**
* Bump last_used_at for a key. Best-effort: failures are swallowed
* because this is a hot-path observability hook and must not
* break the request.
*/
public static function touchLastUsed(int $id): void
{
try {
$db = self::db();
$stmt = $db->conn()->prepare(
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
);
if ($stmt === false) {
return;
}
$stmt->bind_param('i', $id);
$stmt->execute();
$stmt->close();
} catch (Throwable) {
// intentionally ignored
}
}
/**
* List keys for a customer, newest first.
*
* @return array<int, array<string, mixed>>
*/
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
{
$db = self::db();
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
if (!$includeRevoked) {
$sql .= ' AND revoked_at IS NULL';
}
$sql .= ' ORDER BY id DESC';
$stmt = $db->conn()->prepare($sql);
if ($stmt === false) {
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
}
$stmt->bind_param('i', $customerId);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute list: ' . $err);
}
$result = $stmt->get_result();
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
$stmt->close();
return is_array($rows) ? $rows : [];
}
/**
* Delete a key by id. Returns true if a row was removed.
* Generally prefer `revoke()` over `delete()` so audit trails
* stay intact.
*/
public static function delete(int $id): bool
{
$db = self::db();
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
if ($stmt === false) {
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
$ok = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
return $ok && $affected > 0;
}
}
@@ -0,0 +1,92 @@
<?php
namespace classes;
/**
* Schema bootstrap for the api_keys table.
*
* This codebase does NOT use a migration framework; new tables are
* added via `*_schema_bootstrap.php` files that run idempotent
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
* is the human-readable source of truth / change record.
*/
class api_key_schema_bootstrap
{
public const TABLE = 'api_keys';
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db)) {
// No DB connection in this process (e.g. unit test) — skip.
self::$initialized = true;
return;
}
$queries = [
"CREATE TABLE IF NOT EXISTS api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $query) {
try {
$db->query($query);
} catch (\Throwable $e) {
// Swallow on first-failure in unit-test contexts; the
// migration companion file documents the canonical DDL.
if (function_exists('error_log')) {
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
}
}
}
self::$initialized = true;
}
public static function tableExists(): bool
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
return false;
}
try {
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = '{$database}'
AND table_name = 'api_keys'"
);
$row = $result ? $result->fetch_assoc() : ['count' => 0];
return (int)($row['count'] ?? 0) > 0;
} catch (\Throwable) {
return false;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace app\auth;
/**
* Scope constants and helpers.
*
* LOCAL STUB for TRU-149 — will be replaced/extended by TRU-145
* (branch feat/api-key-foundation). Keeping this minimal so we
* don't conflict with the parallel scope-system work.
*
* Adding a new scope? Add the constant here AND register it in
* Scope::all() AND in Scope::forRole() (whichever roles should
* carry it). Centralising role → scope mapping here keeps
* permission decisions auditable in one place.
*/
class Scope
{
const CUSTOMER_READ = 'customer:read';
const CUSTOMER_WRITE = 'customer:write';
const BOOKING_READ = 'booking:read';
const BOOKING_WRITE = 'booking:write';
const SUBUSER_READ = 'subuser:read';
const SUBUSER_WRITE = 'subuser:write';
const INVOICE_READ = 'invoice:read';
const INVOICE_WRITE = 'invoice:write';
const SUPERUSER_READ = 'superuser:read';
const SUPERUSER_WRITE = 'superuser:write';
/**
* Canonical list of every scope. Used for validation and to
* build the audit trail of "what scopes exist" in tests.
*/
public static function all(): array
{
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
];
}
/**
* Return the scopes carried by a given role. Single source of
* truth for role-based scope assignment.
*/
public static function forRole(string $role): array
{
switch ($role) {
case 'superuser':
return self::all();
case 'admin':
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
];
case 'customer':
// TRU-149 (fix): customers get WRITE on their own data so
// self-service endpoints (own vehicles, own subusers, own
// discount / security / notification settings, own bookings)
// work end-to-end. The existing fine-grained
// requirePermission() calls in each route still gate which
// specific actions are allowed — scope here only answers
// "can this caller write customer data at all".
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ,
];
case 'subuser':
return [self::BOOKING_READ, self::BOOKING_WRITE];
default:
return [];
}
}
/**
* Normalize/validate a scope string. Returns null on invalid input
* (empty string, non-string, or not in the canonical set).
*
* Wildcards: "*" matches every scope. "customer:*" matches every
* scope starting with "customer:". "customer:read" matches itself.
*/
public static function matches(string $granted, string $required): bool
{
$granted = trim($granted);
$required = trim($required);
if ($granted === '' || $required === '') {
return false;
}
if ($granted === '*') {
return true;
}
if (str_ends_with($granted, ':*')) {
$prefix = substr($granted, 0, -2);
return str_starts_with($required, $prefix . ':');
}
return $granted === $required;
}
}
@@ -0,0 +1,314 @@
<?php
namespace app\auth;
use classes\response;
use Exception;
use objects\logs_o;
use objects\subusers_o;
use objects\users_o;
/**
* Scope-based access control middleware.
*
* Sits ON TOP of the existing session-cookie / bearer-token auth in
* classes\authentication. Existing permission checks (requirePermission,
* requireDepartmentAccess, etc.) MUST stay in place — scope checks are
* an additional, parallel layer that lets us reason about route
* authorization in terms of coarse-grained capabilities ("can this
* caller read invoices?") rather than fine-grained permission strings.
*
* The scope source-of-truth is app\auth\Scope (a local stub for
* TRU-149 — replaced by TRU-145 / feat/api-key-foundation).
*
* Three entry points:
* - requireScope(string) — caller must hold this exact scope
* - requireAnyScope(array) — caller must hold at least one
* - requireRole(string) — convenience: any of the role's
* scopes (see Scope::forRole)
*
* All three throw 403 on missing scope (or 401 if not authenticated at
* all). They never short-circuit silently: a missing scope is a denial,
* not a no-op.
*/
class ScopeMiddleware
{
/**
* Throw 403 unless the caller carries the given scope.
*
* @param string $required Scope string (e.g. Scope::CUSTOMER_READ).
* @param string|null $context Free-form label for log output
* (typically the route path).
*/
public static function requireScope(string $required, ?string $context = null): void
{
$granted = self::resolveGrantedScopes();
if (self::hasAnyMatchingScope($granted, [$required])) {
return;
}
self::deny($required, $granted, $context);
}
/**
* Throw 403 unless the caller carries at least one of the given scopes.
*
* @param array<int, string> $required
*/
public static function requireAnyScope(array $required, ?string $context = null): void
{
if ($required === []) {
// No scopes required = nothing to enforce. Defensive: a route
// author who passes [] probably meant to skip scope checks, so
// let it through rather than denying.
return;
}
$granted = self::resolveGrantedScopes();
if (self::hasAnyMatchingScope($granted, $required)) {
return;
}
self::deny(implode('|', $required), $granted, $context);
}
/**
* Convenience wrapper: require that the caller's role is at least
* as privileged as the named role.
*
* Role hierarchy: superuser > admin > customer > subuser.
* A caller satisfies `requireRole('admin')` if they are admin or
* superuser. `requireRole('superuser')` is only satisfied by
* superuser.
*
* Unknown roles deny.
*/
public static function requireRole(string $role, ?string $context = null): void
{
$hierarchy = ['subuser' => 1, 'customer' => 2, 'admin' => 3, 'superuser' => 4];
if (!isset($hierarchy[$role])) {
global $response;
if (is_object($response) && method_exists($response, 'error')) {
$response->error('Unknown role for scope check: ' . $role, 403);
}
return;
}
$callerRole = self::resolveCallerRole();
if ($callerRole === null) {
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
return;
}
$callerRank = $hierarchy[$callerRole] ?? 0;
$requiredRank = $hierarchy[$role];
if ($callerRank >= $requiredRank) {
return;
}
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
}
/**
* Resolve the caller's role name. Returns null if no principal
* is authenticated (or only anonymous test state exists).
*/
public static function resolveCallerRole(): ?string
{
// Test hook: tests can install a role via the
// setTestPrincipal() path; that path also stores the synthetic
// role directly when passed as a string key. For now we
// infer the role from the granted-scopes list.
if (self::$testPrincipal !== null) {
$granted = self::$testPrincipal;
if (in_array(Scope::SUPERUSER_READ, $granted, true) && in_array(Scope::SUPERUSER_WRITE, $granted, true)) {
return 'superuser';
}
if (in_array(Scope::SUBUSER_WRITE, $granted, true)) {
return 'subuser';
}
if (in_array(Scope::INVOICE_WRITE, $granted, true)) {
return 'admin';
}
if (in_array(Scope::CUSTOMER_READ, $granted, true)) {
return 'customer';
}
return null;
}
try {
$auth = new \classes\authentication();
$user = $auth->get_user();
if ($user instanceof users_o) {
return self::userRole($user);
}
$sub = $auth->get_subuser();
if ($sub instanceof subusers_o) {
return 'subuser';
}
} catch (Exception) {
// fall through
}
return null;
}
/**
* Pure check (no throw). Useful for hasScope() style predicates in
* route handlers that want to branch on capabilities.
*
* @return bool true if the caller has the scope (or is superuser/admin).
*/
public static function hasScope(string $required): bool
{
$granted = self::resolveGrantedScopes();
return self::hasAnyMatchingScope($granted, [$required]);
}
/**
* Pure check for "any of" matching. Returns false if the caller is
* not authenticated at all (so callers can branch on anonymous).
*
* @param array<int, string> $required
*/
public static function hasAnyScope(array $required): bool
{
if ($required === []) {
return true;
}
$granted = self::resolveGrantedScopes();
return self::hasAnyMatchingScope($granted, $required);
}
/**
* Resolve the scopes the current principal carries. For now this is
* derived from the classic user role / subuser permissions, since
* the API key plumbing (TRU-145) is not yet wired in. When TRU-145
* lands, this method is the single replacement point.
*
* Returns an empty array if no principal is authenticated.
*
* @return array<int, string>
*/
public static function resolveGrantedScopes(): array
{
// Test hook: if a test has installed a principal via
// self::setTestPrincipal(), honour that and skip the real
// authentication path. This is the only place a test-only
// branch lives; production code never sets the test
// principal because nothing else in the codebase does.
if (self::$testPrincipal !== null) {
$principal = self::$testPrincipal;
if (is_array($principal)) {
return $principal;
}
}
try {
$auth = new \classes\authentication();
$user = $auth->get_user();
if ($user instanceof users_o) {
$role = self::userRole($user);
return Scope::forRole($role);
}
$sub = $auth->get_subuser();
if ($sub instanceof subusers_o) {
return Scope::forRole('subuser');
}
} catch (Exception) {
// fall through
}
return [];
}
/** @var array<int, string>|null */
private static ?array $testPrincipal = null;
/**
* Test-only: set the scope list the middleware should treat as
* "granted" for the current request. Pass null to clear.
*
* @param array<int, string>|null $scopes
*/
public static function setTestPrincipal(?array $scopes): void
{
self::$testPrincipal = $scopes;
}
/**
* Best-effort role detection for an authenticated user.
*
* Order of preference:
* 1. `hasPermission('superuser')` — matches the pattern used
* elsewhere in the codebase (e.g. departmentGoalsRoute).
* 2. `hasPermission('admin')` — admin gets the admin scope set.
* 3. Fallback to 'customer' — most authenticated callers are
* customer users, so we treat unknown as customer (read-only)
* rather than zero-privilege. This matches existing routes'
* behavior of allowing read access by default.
*
* Anonymous / malformed sessions yield no scopes via
* resolveGrantedScopes()'s outer try/catch.
*/
private static function userRole(users_o $user): string
{
try {
if (method_exists($user, 'hasPermission')) {
if ((bool)$user->hasPermission('superuser')) {
return 'superuser';
}
if ((bool)$user->hasPermission('admin')) {
return 'admin';
}
}
} catch (Exception) {
// fall through to default
}
return 'customer';
}
/**
* Check if any of the granted scopes satisfies any of the required
* scopes, using Scope::matches() (which supports "*" and "x:*"
* wildcards).
*
* @param array<int, string> $granted
* @param array<int, string> $required
*/
private static function hasAnyMatchingScope(array $granted, array $required): bool
{
foreach ($required as $need) {
foreach ($granted as $have) {
if (Scope::matches($have, $need)) {
return true;
}
}
}
return false;
}
/**
* Emit a 403 with a consistent shape and log the denial so we can
* see attempted access patterns during rollout.
*/
private static function deny(string $required, array $granted, ?string $context): void
{
// Best-effort log of the denial. We swallow all errors here
// because the deny path itself must never throw — a 403
// response is the contract.
try {
// The `redis` constant is a global namespaced object
// (objects\redis) created at boot. In test environments
// it may not be defined, so guard with `defined()`.
if (class_exists(logs_o::class) && defined('redis')) {
(new logs_o())->add(
'global',
'global',
1,
0,
'SCOPE_DENIED',
'Missing scope: ' . $required . ' (context=' . ($context ?? 'n/a') . ', granted=' . implode(',', $granted) . ')'
);
}
} catch (Throwable) {
// Logging must never block a deny.
}
global $response;
if (is_object($response) && method_exists($response, 'error')) {
$response->error('Missing required scope: ' . $required, 403);
return;
}
throw new Exception('Forbidden: missing scope ' . $required, 403);
}
}
@@ -0,0 +1,221 @@
<?php
namespace classes\auth;
/**
* Scope registry: the source of truth for API key scopes and
* role → scope defaults.
*
* This class is the canonical implementation that the parallel
* `app\auth\Scope` stub (introduced by TRU-149 / branch
* feat/TRU-149-route-scopes) will be replaced with once
* `feat/api-key-foundation` is merged. Until then the two can
* coexist; the middleware in `scope_middleware.php` continues
* to use the legacy stub.
*
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
* Two wildcard forms are recognised:
* - `*` — matches every scope.
* - `resource:*` — matches every action on a resource.
*
* Role defaults:
* - superuser: every scope (via "*" wildcard).
* - admin: customer:*, booking:*, subuser:*, invoice:*
* - customer: customer:read, booking:read, invoice:read
* - subuser: booking:read, booking:write
*
* The "self" / "assigned" qualifiers from the spec are *enforcement
* layer* concerns, not scope concerns — they live in the resolver
* that maps an authenticated principal to a customer/subuser record.
* Scopes only encode "can the caller read bookings at all", not
* "which bookings".
*/
final class scope_registry
{
// --- Customer resource ---
public const CUSTOMER_READ = 'customer:read';
public const CUSTOMER_WRITE = 'customer:write';
// --- Booking resource ---
public const BOOKING_READ = 'booking:read';
public const BOOKING_WRITE = 'booking:write';
// --- Subuser resource ---
public const SUBUSER_READ = 'subuser:read';
public const SUBUSER_WRITE = 'subuser:write';
// --- Invoice resource ---
public const INVOICE_READ = 'invoice:read';
public const INVOICE_WRITE = 'invoice:write';
// --- Superuser / admin resource ---
public const SUPERUSER_READ = 'superuser:read';
public const SUPERUSER_WRITE = 'superuser:write';
/**
* Canonical list of every concrete scope (no wildcards).
*
* @return array<int, string>
*/
public static function all(): array
{
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
];
}
/**
* Return the default scope set carried by a role. Wildcards are
* returned as-is; resolve them with `expand()` before checking
* membership if you need a flat list.
*
* @return array<int, string>
*/
public static function scopesForRole(string $role): array
{
switch (strtolower(trim($role))) {
case 'superuser':
return ['*'];
case 'admin':
return [
'customer:*',
'booking:*',
'subuser:*',
'invoice:*',
];
case 'customer':
return [
self::CUSTOMER_READ,
self::BOOKING_READ,
self::INVOICE_READ,
];
case 'subuser':
return [
self::BOOKING_READ,
self::BOOKING_WRITE,
];
default:
return [];
}
}
/**
* Does the granted scope (or wildcard) match the required scope?
*
* - "*" matches anything.
* - "customer:*" matches "customer:read" and "customer:write".
* - "customer:read" matches itself exactly.
*
* @param array<int, string> $granted
*/
public static function hasScope(array $granted, string $required): bool
{
$required = trim($required);
if ($required === '') {
return false;
}
foreach ($granted as $candidate) {
if (!is_string($candidate)) {
continue;
}
if (self::matches($candidate, $required)) {
return true;
}
}
return false;
}
/**
* Expand a list of scopes (which may include wildcards) into the
* full set of concrete scopes they grant. Useful for showing a
* user what their key can do, or for caching decisions.
*
* The wildcard "*" expands to the full `all()` set. A wildcard
* like "customer:*" expands to every concrete scope starting with
* "customer:". Duplicate entries are removed.
*
* @param array<int, string> $scopes
* @return array<int, string>
*/
public static function expand(array $scopes): array
{
$concrete = self::all();
$expanded = [];
foreach ($scopes as $scope) {
if (!is_string($scope)) {
continue;
}
$scope = trim($scope);
if ($scope === '') {
continue;
}
if ($scope === '*') {
$expanded = array_merge($expanded, $concrete);
continue;
}
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -2) . ':';
foreach ($concrete as $candidate) {
if (str_starts_with($candidate, $prefix)) {
$expanded[] = $candidate;
}
}
continue;
}
// Already concrete — pass through if it looks canonical.
if (in_array($scope, $concrete, true)) {
$expanded[] = $scope;
}
}
return array_values(array_unique($expanded));
}
/**
* Internal wildcard matcher — public for testing.
*/
public static function matches(string $granted, string $required): bool
{
$granted = trim($granted);
$required = trim($required);
if ($granted === '' || $required === '') {
return false;
}
if ($granted === '*') {
return true;
}
if (str_ends_with($granted, ':*')) {
$prefix = substr($granted, 0, -2);
return str_starts_with($required, $prefix . ':');
}
return $granted === $required;
}
/**
* Validate a scope string. Returns true iff the value is either
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
* for a known resource.
*/
public static function isValid(string $scope): bool
{
$scope = trim($scope);
if ($scope === '' || $scope === '*') {
return $scope !== '';
}
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -2);
foreach (self::all() as $concrete) {
if (str_starts_with($concrete, $prefix . ':')) {
return true;
}
}
return false;
}
return in_array($scope, self::all(), true);
}
}
@@ -0,0 +1,380 @@
<?php
namespace classes;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;
/**
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
*
* The toggle lives in the existing `customer_attributes` table under the
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
* except the 3rd business day of the month, where it auto-queues ready
* collected invoices for the opted-in customers.
*
* "Business day" is computed against a locale-aware weekend (default
* Saturday + Sunday). Public Danish holidays are supported via a small
* override hook so the unit tests can pin the result without depending on
* the wall clock.
*/
class auto_send_invoice_third_business_day_service
{
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
/** @var list<string>|null */
private ?array $holidayCache = null;
/** @var callable|null */
private $holidayProviderOverride = null;
/** @var callable|null */
private $nowProviderOverride = null;
/** @var callable|null */
private $timeZoneProviderOverride = null;
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
{
$timezone = $this->resolveTimezone();
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
$businessDay = 0;
$cursor = $reference->setDate(
(int)$reference->format('Y'),
(int)$reference->format('n'),
1
);
$today = $reference;
while ($cursor <= $today) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
}
}
$cursor = $cursor->modify('+1 day');
}
return false;
}
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
{
if ($year < 1970 || $year > 9999) {
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
}
if ($month < 1 || $month > 12) {
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
}
$timezone = $this->resolveTimezone();
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
->setTimezone($timezone);
$businessDay = 0;
while (true) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->setTime(0, 0, 0);
}
}
$cursor = $cursor->modify('+1 day');
}
}
public function isBusinessDay(DateTimeImmutable $date): bool
{
$weekday = (int)$date->format('N');
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
return false;
}
$holidayKey = $date->format('Y-m-d');
foreach ($this->resolveHolidays() as $holiday) {
if ($holiday === $holidayKey) {
return false;
}
}
return true;
}
/**
* Auto-queue every ready collected invoice for customers with the
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
* what was queued (or an empty `scanned` count when invoked on a
* non-trigger day).
*
* @return array{
* triggered: bool,
* trigger_date: ?string,
* customers: int,
* collections_scanned: int,
* jobs_enqueued: int,
* skipped_already_queued: int,
* errors: list<array{customer_number:int,collection_id:int,message:string}>
* }
*/
public function runOnce(?DateTimeImmutable $now = null): array
{
$timezone = $this->resolveTimezone();
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
$summary = [
'triggered' => false,
'trigger_date' => null,
'customers' => 0,
'collections_scanned' => 0,
'jobs_enqueued' => 0,
'skipped_already_queued' => 0,
'errors' => [],
];
if (!$this->isThirdBusinessDay($today)) {
return $summary;
}
$summary['triggered'] = true;
$summary['trigger_date'] = $today->format('Y-m-d');
$customerNumbers = $this->loadEligibleCustomerNumbers();
$summary['customers'] = count($customerNumbers);
if ($customerNumbers === []) {
return $summary;
}
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
$summary['collections_scanned'] = count($collections);
if ($collections === []) {
return $summary;
}
$queue = $this->createTransferQueue();
foreach ($collections as $collection) {
$collectionId = (int)($collection['id'] ?? 0);
if ($collectionId < 1) {
continue;
}
try {
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
} catch (Throwable $throwable) {
$summary['errors'][] = [
'customer_number' => (int)($collection['customer_number'] ?? 0),
'collection_id' => $collectionId,
'message' => $throwable->getMessage(),
];
continue;
}
if ($enqueued === 'queued') {
$summary['jobs_enqueued']++;
} elseif ($enqueued === 'already_queued') {
$summary['skipped_already_queued']++;
}
// 'unavailable' is intentionally silent: the queue is optional
// and the next cron tick will pick up the collections.
}
return $summary;
}
public function setHolidayProviderOverride(callable $provider): void
{
$this->holidayProviderOverride = $provider;
}
public function setNowProviderOverride(callable $provider): void
{
$this->nowProviderOverride = $provider;
}
public function setTimeZoneProviderOverride(callable $provider): void
{
$this->timeZoneProviderOverride = $provider;
}
public function clearOverrides(): void
{
$this->holidayProviderOverride = null;
$this->nowProviderOverride = null;
$this->timeZoneProviderOverride = null;
$this->holidayCache = null;
}
/** @return list<int> */
public function loadEligibleCustomerNumbers(): array
{
global $db;
$attribute = $db->escape_string(self::ATTRIBUTE);
$result = $db->query(
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
FROM customer_attributes ca
INNER JOIN users u ON u.id = ca.user_id
WHERE ca.attribute = '{$attribute}'
AND u.customer_number IS NOT NULL
AND u.customer_number <> 0
AND u.deleted_at IS NULL
ORDER BY customer_number ASC"
);
if (!$result) {
return [];
}
$customerNumbers = [];
while ($row = $result->fetch_assoc()) {
$number = (int)($row['customer_number'] ?? 0);
if ($number > 0) {
$customerNumbers[] = $number;
}
}
return $customerNumbers;
}
/**
* @param list<int> $customerNumbers
* @return list<array<string,mixed>>
*/
public function loadReadyInvoiceCollections(array $customerNumbers): array
{
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
if ($customerNumbers === []) {
return [];
}
global $db;
$in = implode(',', $customerNumbers);
// A collection is "ready" when:
// - it belongs to one of the opted-in customers
// - it has at least one order linked to it
// - it has not been booked yet (no booked_at)
// - it has not been closed yet (no closed_at) — closures are reserved
// for already-booked/manual-approval flows
// - it has not been deleted
$result = $db->query(
"SELECT c.id, c.customer_number
FROM collected_order_invoices c
WHERE c.customer_number IN ({$in})
AND c.deleted_at IS NULL
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
AND EXISTS (
SELECT 1 FROM orders o
WHERE o.invoice_collection_id = c.id
AND o.deleted_at IS NULL
)
ORDER BY c.customer_number ASC, c.id ASC"
);
if (!$result) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'customer_number' => (int)($row['customer_number'] ?? 0),
];
}
return $rows;
}
/**
* Hook for unit tests: when the production economic_transfer_queue is
* not available the cron task should still succeed with no jobs.
*/
protected function createTransferQueue(): ?economic_transfer_queue
{
if (!class_exists(economic_transfer_queue::class)) {
return null;
}
try {
return new economic_transfer_queue();
} catch (Throwable) {
return null;
}
}
/**
* @return 'queued'|'already_queued'|'unavailable'
*/
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
{
if ($queue === null) {
return 'unavailable';
}
try {
$job = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => $collectionId,
'send_as_is' => false,
'requested_by' => 0,
'auto_send_third_business_day' => true,
],
0
);
} catch (Throwable $throwable) {
// The queue is designed to refuse duplicate enqueue by
// surfacing a domain-specific message — treat that as
// "already_queued" rather than a hard failure.
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
return 'already_queued';
}
throw $throwable;
}
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
}
/** @return list<string> */
private function resolveHolidays(): array
{
if ($this->holidayCache !== null) {
return $this->holidayCache;
}
if ($this->holidayProviderOverride !== null) {
$value = ($this->holidayProviderOverride)();
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
$this->holidayCache = $holidays;
return $holidays;
}
// Default: no hard-coded public holidays. Production can plug a
// concrete provider through setHolidayProviderOverride() once the
// holiday calendar is finalised. The Saturday/Sunday weekend
// logic is sufficient for the 3rd-business-day computation as
// long as the cron task runs on every weekday.
$this->holidayCache = [];
return $this->holidayCache;
}
private function resolveNow(): DateTimeImmutable
{
if ($this->nowProviderOverride !== null) {
$value = ($this->nowProviderOverride)();
if ($value instanceof DateTimeImmutable) {
return $value;
}
}
return new DateTimeImmutable('now');
}
private function resolveTimezone(): DateTimeZone
{
if ($this->timeZoneProviderOverride !== null) {
$value = ($this->timeZoneProviderOverride)();
if ($value instanceof DateTimeZone) {
return $value;
}
if (is_string($value) && $value !== '') {
return new DateTimeZone($value);
}
}
return new DateTimeZone('Europe/Copenhagen');
}
}
@@ -54,6 +54,7 @@ class customer_rule_product_restriction_service
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
'autoSendInvoiceThirdBusinessDay',
];
public function __construct()
@@ -0,0 +1,111 @@
<?php
namespace classes;
/**
* Sanitizes user-input fields that are sent to the e-conomic API.
*
* Background: e-conomic returns 400 errors when description fields contain
* certain characters. The known issue is "/" in the order reference field
* (TRU-188), but we sanitize defensively for all such cases.
*
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
* - sanitizeProductNumber(): for product identifiers
* - sanitizeProductDescription(): for product-line descriptions
* - sanitizeForEconApi(): catch-all for arbitrary user input
*/
class economic_export_sanitizer
{
/** E-conomic soft limit for a single description line. */
public const TEXT_LINE_MAX_LENGTH = 250;
/** E-conomic soft limit for a product description. */
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
/** E-conomic soft limit for a product number. */
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
/** Characters that are illegal in product numbers on most e-conomic setups. */
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
/**
* Sanitize a value for use in a single-line text description.
*
* Transformations (in order):
* 1. Replaces "/" with "-" (the reported 400 trigger)
* 2. Strips control characters (\x00-\x1F) except \t and \n
* 3. Replaces tab with single space
* 4. Collapses newlines into spaces (text lines are single-line)
* 5. Collapses runs of spaces to a single space
* 6. Trims leading/trailing whitespace
* 7. Truncates to $maxLength with "..." suffix if needed
*/
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
// 1. Strip control characters except \t and \n
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
// 2. Replace tab with single space
$text = str_replace("\t", ' ', $text);
// 3. Collapse newlines to single space (text lines are single-line)
$text = preg_replace('/[\r\n]+/u', ' ', $text);
// 4. Replace forward slashes (the reported 400 trigger)
$text = str_replace('/', '-', $text);
// 5. Collapse runs of spaces
$text = preg_replace('/\s+/u', ' ', $text);
// 6. Trim
$text = trim($text);
// 7. Truncate with ellipsis if too long
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength - 3) . '...';
} elseif (mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength);
}
return $text;
}
/**
* Sanitize a product number/identifier.
*
* Removes characters that are illegal in product numbers on most
* e-conomic setups (filesystem-unsafe + path separators).
*/
public static function sanitizeProductNumber(mixed $value): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
$text = trim($text);
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
}
return $text;
}
/**
* Sanitize a longer product description.
*/
public static function sanitizeProductDescription(mixed $value): string
{
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
}
/**
* Catch-all sanitizer for any user-input value going to e-conomic.
* Defaults to text-line rules.
*/
public static function sanitizeForEconApi(mixed $value): string
{
return self::sanitizeTextLine($value);
}
}
@@ -0,0 +1,92 @@
<?php
namespace classes;
/**
* Centralized selection of e-conomic invoice layout numbers.
*
* This class is the **skeleton** introduced by TRU-197. It exposes the two
* layout numbers that the backend should use for the two invoice variants:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
*
* The constants below are placeholders for the layout numbers that the
* e-conomic account admin must pick in e-conomic (Settings → Design and
* Layouts) and write into the module-config DB variables
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
* themselves are intentionally left as `0` in this skeleton — they are
* resolved at runtime from the module-config variables by the two existing
* call sites:
*
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
*
* Wiring those call sites to read from this selector (instead of from the
* module-config variables directly) is intentionally **out of scope** for
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
* full audit and follow-up plan.
*
* Constants in this class are the *single source of truth* for the
* env-var-style aliases:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
*/
class economic_layout_selector
{
/**
* Layout number for invoices WITHOUT itemized discount lines.
*
* Intent: a clean invoice — no "Rabat" line, no discount column, just
* the line items and totals.
*
* @var int
*/
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
/**
* Layout number for invoices WITH itemized discount lines.
*
* Intent: an invoice that visibly itemizes the negative `Rabat`
* (product `TotDiscount`) line so the customer can see the discount
* broken out instead of folded into per-product `discountPercentage`.
*
* @var int
*/
public const LAYOUT_WITH_DISCOUNTS = 0;
/**
* Module-config variable name for the without-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
/**
* Module-config variable name for the with-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
/**
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithoutDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
}
/**
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
<?php
namespace classes;
/**
* Self-healing schema bootstrap.
*
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
* production database always has the columns the current code expects.
* This catches the "merged-to-master-but-never-applied-to-prod" failure
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
* step didn't run (missing GitHub secrets, network glitch, etc.).
*
* Each bootstrap is **additive + idempotent**:
* - SHOW COLUMNS check before any ALTER
* - ALTER TABLE ADD COLUMN only if missing
* - Once `ensureSchema()` has been called once for a class, the static
* `$initialized` flag short-circuits subsequent calls
*
* The discovery + run loop itself is memoized per PHP process via
* `self::$ran`, so the cost after the first request is a single
* `class_exists` check (~microseconds).
*
* Errors in a single bootstrap are logged but never throw — a broken
* migration must not 500 every request. A future /api/admin/schema-check
* call will surface the failure.
*/
class schema_bootstrap_runtime
{
/** @var bool Memoization for the discovery+run loop */
private static bool $ran = false;
/** @var string[] Class names that already failed this process (don't retry) */
private static array $failed = [];
public static function runAll(): void
{
if (self::$ran) {
return;
}
self::$ran = true;
$classesDir = __DIR__;
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
if (!$bootstraps) {
return;
}
foreach ($bootstraps as $file) {
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (in_array($class, self::$failed, true)) {
continue;
}
try {
if (!class_exists($class)) {
require_once $file;
}
if (!class_exists($class)) {
continue;
}
if (!method_exists($class, 'ensureSchema')) {
continue;
}
$class::ensureSchema();
} catch (\Throwable $e) {
self::$failed[] = $class;
error_log(sprintf(
'[schema-bootstrap] %s failed: %s',
$base,
$e->getMessage()
));
// Intentionally do not throw — a broken migration must
// not 500 every request. The next /api/admin/schema-check
// call (or the next deploy's pre-deploy step) will
// surface the failure.
}
}
}
}
+65 -3
View File
@@ -33,17 +33,17 @@ class slack implements notification_i
public function send_department_booking_notification(int $department_id, $message): self
{
// Get the departments webhook
$webhook = self::get_department_webhook($department_id);
$webhook = static::get_department_webhook($department_id);
// Check if the webhook is empty
if (empty($webhook)) {
throw new \Exception('Department webhook is empty');
}
// Send the notification to the department
self::add_log(self::send_webhook_message($message, $webhook));
self::add_log(static::send_webhook_message($message, $webhook));
return $this;
}
private function get_department_webhook(int $department_id): string|null
protected function get_department_webhook(int $department_id): string|null
{
// Check if the department webhook is cached
$webhook = redis->get_department_webhook($department_id);
@@ -134,6 +134,68 @@ class slack implements notification_i
. "Status: $status";
}
/**
* Send a new-booking notification to the department's Slack webhook.
*
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
* bookings (pickup_bool === false) are intentionally silenced per
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
* channel. Other delivery channels (SMS, email) are unaffected.
*
* Returns true if a Slack message was sent, false if it was filtered
* out (drop-off) or the department has no Slack webhook configured.
*
* @throws \Exception If the department lookup or webhook send fails.
*/
public function send_new_booking_notification(
$id,
$customer_number,
string $wash_type,
string $contact_email,
string $reference_number,
string $regNrTraekker,
string $regNrTrailer,
string $washCertificateEmail,
string $date,
int $department,
bool $pickup_bool,
string $notes,
string $washCertificateStatus,
string $washCertificateUrl,
string $status
): bool {
// TRU-106: drop-off bookings must not post to Slack.
if (!$pickup_bool) {
return false;
}
$webhook = static::get_department_webhook($department);
if (empty($webhook)) {
return false;
}
$message = static::format_new_booking(
$id,
$customer_number,
$wash_type,
$contact_email,
$reference_number,
$regNrTraekker,
$regNrTrailer,
$washCertificateEmail,
$date,
$department,
$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
);
self::add_log(static::send_webhook_message($message, $webhook));
return true;
}
public function send_message(string $string, ?string $module = null): void
{
global $SLACK_DEFAULT_WEBHOOK;
@@ -9,6 +9,13 @@ class system_search_economic_customer_index
{
public const TABLE = 'system_search_economic_customer_index';
/**
* FULLTEXT key name used by TRU-62 customer-search performance fix.
* The column already exists (TEXT NULL `search_text`) — we just need
* the index. See documentation/perf/customer-search-slow-investigation.md.
*/
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
private static bool $initialized = false;
public static function ensureTable(): void
@@ -50,6 +57,14 @@ class system_search_economic_customer_index
'economic_barred',
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
);
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
// already exists. The search code falls back to the LIKE-based query
// when this index is absent, so an incomplete migration is non-fatal.
self::ensureIndex(
self::FULLTEXT_INDEX,
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
);
self::$initialized = true;
}
@@ -362,6 +377,39 @@ class system_search_economic_customer_index
}
}
/**
* Ensure an index (FULLTEXT or otherwise) exists on the table.
* No-ops when the index is already present so this is safe to call
* repeatedly at request time.
*/
private static function ensureIndex(string $indexName, string $alterSql): void
{
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
try {
$result = $db->query(
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
. $db->escape_string($indexName) . "'"
);
} catch (Throwable) {
return;
}
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
try {
$db->query($alterSql);
} catch (Throwable $e) {
// The search code falls back to the LIKE path when the
// index is missing, so a failed ALTER is non-fatal.
if (function_exists('error_log')) {
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
}
}
}
}
private static function barredStatus(?bool $barred): string
{
return match ($barred) {
@@ -720,52 +720,18 @@ class system_search_service
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
}
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
// TRU-62: prefer the FULLTEXT path against the denormalized
// `system_search_economic_customer_index.search_text` column. The
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
// clauses, which dominated the ~10s request latency reported in
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
// index is missing (e.g. migration not yet applied) or returns
// zero rows for the query.
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
if ($rows === null) {
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
return array_map(function (array $row) use ($terms, $entityBoost) {
$title = trim((string)($row['economic_name'] ?? ''));
if ($title === '') {
@@ -818,6 +784,166 @@ class system_search_service
}, $rows);
}
/**
* TRU-62 — FULLTEXT path for customer search.
*
* Returns the matching rows from `users LEFT JOIN
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
* query against the denormalized `search_text` column. This replaces
* the 13-clause `LIKE '%term%'` OR chain that previously caused
* ~10s customer-search latency. Returns `null` when the FULLTEXT
* path is not available (index missing) or the boolean query is
* empty (terms too short for the FULLTEXT minimum word length);
* callers should then fall back to {@see searchCustomersWithLike()}.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>|null
*/
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
{
if (empty($terms)) {
return [];
}
if (!$this->isFulltextCustomerIndexAvailable()) {
return null;
}
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
if ($booleanQuery === null) {
// One or more terms are too short for the FULLTEXT minimum
// word length. The LIKE path is the only viable option.
return null;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return null;
}
$escaped = $db->escape_string($booleanQuery);
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$fromClause = 'users u LEFT JOIN `'
. system_search_economic_customer_index::TABLE
. '` sci ON sci.customer_number = u.customer_number';
$sql = "SELECT " . implode(', ', $selectFields)
. " FROM " . $fromClause
. " WHERE 1=1" . $customerFilter
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
. " LIMIT " . $this->defaultEntityFetchLimit;
$rows = $this->runSelectRows($sql);
if (empty($rows)) {
// FULLTEXT is in use but the row set is empty. We could fall
// back to LIKE here, but a fully-empty FULLTEXT result for a
// customer-tab query usually means "no match" (the boolean
// query already required all terms to be present). Avoid the
// extra full-table scan and return an empty result set.
return [];
}
return $rows;
}
/**
* TRU-62 — original LIKE-based fallback for customer search. Kept
* verbatim so that deployments which have not yet applied the
* FULLTEXT migration still get correct results, just slowly.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>
*/
private function searchCustomersWithLike(array $terms, string $customerFilter): array
{
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
}
return $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
}
/**
* True when the `system_search_economic_customer_index` table exists
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
* index is added by the runtime schema bootstrap and the companion
* migration at `database/migrations/2026_08_17_000002_*`.
*/
private function isFulltextCustomerIndexAvailable(): bool
{
if (!$this->isEconomicCustomerIndexAvailable()) {
return false;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return false;
}
try {
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
$result = $db->query(
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
. "` WHERE `Key_name` = '" . $indexName . "'"
);
if (!($result instanceof \mysqli_result)) {
return false;
}
return $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
{
$rows = $this->searchTableWithJoin(
+75 -1
View File
@@ -689,6 +689,38 @@ function EconomicTransferQueueCron(): void
}
}
/**
* Customer rule "auto-send invoice on the 3rd business day each month"
* (TRU-70 / DRIFT 9).
*
* The handler is a no-op on every day except the 3rd business day of
* the current month (Europe/Copenhagen timezone). On that day it scans
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
* and enqueues every ready collected invoice for export via the
* existing `economic_transfer_queue` machinery. The actual e-conomic
* send is handled asynchronously by `EconomicTransferQueueCron`.
*/
function AutoSendInvoicesThirdBusinessDay(): void
{
try {
$service = new \classes\auto_send_invoice_third_business_day_service();
$summary = $service->runOnce();
if (!empty($summary['triggered'])) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
. ($summary['trigger_date'] ?? '')
. " customers=" . (int)($summary['customers'] ?? 0)
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
. " errors=" . count($summary['errors'] ?? [])
. "\n";
}
} catch (Throwable $e) {
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
}
}
function PruneSystemSessionActivityCron(): void
{
try {
@@ -1389,7 +1421,49 @@ function GoalsProgressAlertsCron(): void
case Dest::SLACK:
$departments = (array)$goal->departments->value();
$sentToDept = false;
if (count($departments) > 0) {
$internalDepartmentIds = [];
try {
$slackConfig = new Slack();
if (method_exists($slackConfig, 'get_internal_department_ids')) {
$internalDepartmentIds = array_map('intval', (array)$slackConfig->get_internal_department_ids());
}
} catch (Throwable $slackConfigError) {
// Ignore - falls back to per-department webhooks
$internalDepartmentIds = [];
}
$goalDeptIds = [];
foreach ($departments as $deptId) {
if (is_numeric($deptId)) {
$goalDeptIds[] = (int)$deptId;
}
}
$allInternal = count($goalDeptIds) > 0
&& count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0;
if ($allInternal) {
// TRU-76: For internal departments (e.g. Taulov/Taastrup DHL daily
// goal), post to the dedicated internal goal progress webhook
// instead of per-department webhooks, which are typically empty
// for internal locations.
$internalWebhook = '';
try {
$slackInstance = new Slack();
if (method_exists($slackInstance, 'get_internal_department_goal_progress_webhook_url')) {
$internalWebhook = trim((string)$slackInstance->get_internal_department_goal_progress_webhook_url());
}
} catch (Throwable $internalWebhookError) {
$internalWebhook = '';
}
if ($internalWebhook !== '') {
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook);
$sentToDept = true;
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " sent to internal goal progress webhook (departments: " . implode(',', $goalDeptIds) . ")\n";
} else {
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " has only internal departments but internal_department_goal_progress_webhook_url is empty; falling back to per-department webhooks\n";
}
}
if (!$sentToDept && count($departments) > 0) {
foreach ($departments as $deptId) {
if (!is_numeric($deptId)) { continue; }
$dept = (new departments_o())->select((int)$deptId);
@@ -0,0 +1,48 @@
<?php
/**
* Migration: create_api_keys_table
* Issue: TRU-143 — [Backend] API key data model + storage schema
* Date: 2026-08-17
*
* NOTE: This codebase does not run a migration framework; the
* canonical DDL is applied idempotently at runtime by
* `classes/api_key_schema_bootstrap.php`. This file is the
* human-readable change record / source of truth for the schema.
*
* To apply manually:
* mysql -u <user> -p <database> < 2026_08_17_000001_create_api_keys_table.sql
*/
return [
'id' => '2026_08_17_000001_create_api_keys_table',
'issue' => 'TRU-143',
'table' => 'api_keys',
'engine' => 'InnoDB',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'up' => [
"CREATE TABLE IF NOT EXISTS api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
],
'down' => [
'DROP TABLE IF EXISTS api_keys',
],
];
@@ -0,0 +1,42 @@
<?php
/**
* Migration: add_fulltext_to_system_search_economic_customer_index
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
* Date: 2026-08-17
*
* The `system_search_economic_customer_index.search_text` column is a
* denormalized blob containing all customer-name / address / email / phone
* data concatenated. The customer search currently runs
*
* `field LIKE '%term%'`
*
* for 13 fields, which forces a full table scan and dominates the
* ~10s request latency. A FULLTEXT index on the same column lets the
* same search run in tens of milliseconds.
*
* NOTE: This codebase does not run a migration framework; the canonical
* DDL is applied idempotently at runtime by
* `classes/system_search_economic_customer_index::ensureTable()`. This
* file is the human-readable change record / source of truth for the
* schema. See TRU-62 investigation doc at
* `documentation/perf/customer-search-slow-investigation.md`.
*
* To apply manually:
* mysql -u <user> -p <database> \
* -e "ALTER TABLE \`system_search_economic_customer_index\`
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
*/
return [
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
'issue' => 'TRU-62',
'table' => 'system_search_economic_customer_index',
'up' => [
'ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
],
'down' => [
'ALTER TABLE `system_search_economic_customer_index`
DROP INDEX `ft_sseci_search_text`',
],
];
+12
View File
@@ -213,6 +213,18 @@ try {
$response->error($e->getMessage(), 500);
}
// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema()
// once per process. Each is additive + idempotent (SHOW COLUMNS check before
// any ALTER), so this is safe on every request. Catches the
// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77
// invoice_email) even when the deploy pipeline pre-deploy step is skipped
// (missing GitHub secrets, network glitch, manual deploy, etc.).
try {
\classes\schema_bootstrap_runtime::runAll();
} catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage());
}
try {
release_manager::initializeRequestContext();
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
@@ -61,4 +61,16 @@ return [
'estimated_duration_ms' => 3000,
'priority' => 20,
],
[
'id' => 'economic.auto_send_invoices_third_business_day',
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
'name' => 'Auto-send invoices on the 3rd business day each month',
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
'module' => 'economic',
'handler' => 'AutoSendInvoicesThirdBusinessDay',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 25,
],
];
@@ -76,9 +76,14 @@ class economicCustomers extends economic_m
// The discount is global, but e-conomic resolves it through a product-specific
// invoice-line template. For foreign-currency customers some templates can fail
// if that product has no price in the customer currency, so try a few products
// before falling back to zero.
// before falling back to zero. We log every swallowed currency-price failure so
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
// visible in the application log instead of vanishing into the void.
$products = $this->getCustomerProducts($customer_number, 10);
$attempted_products = 0;
$swallowed_errors = 0;
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
$attempted_products++;
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
@@ -86,9 +91,24 @@ class economicCustomers extends economic_m
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
$swallowed_errors++;
error_log(sprintf(
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
$customer_number,
$product_number,
$exception->getMessage()
));
}
}
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
error_log(sprintf(
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
$attempted_products,
$customer_number
));
}
return 0;
}
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
$orders_with_invoice_lines = 0;
@@ -89,8 +89,8 @@ class economic_invoices_draft_endpoint
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
// Add the order lines (including the customer-level e-conomic discount, if any).
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
}
@@ -125,10 +125,15 @@ class economic_invoices_drafts_endpoint
$customer = (new economic())->getCustomer($customer_number);
// Set the recipient details
$customer_name = $customer->getName() ?? 'Ukendt';
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
// Note: customer_* fields come from e-conomic itself (controlled input),
// but we sanitize them defensively to avoid 400s if e-conomic ever stores
// a value with chars e-conomic later rejects in the recipient block.
// Each field uses an appropriate length cap to match the corresponding
// e-conomic recipient field limits.
$customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100);
$customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250);
$customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20);
$customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100);
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
@@ -139,9 +144,14 @@ class economic_invoices_drafts_endpoint
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
$recipient['nemHandelType'] = 'ean';
if ($customer_ean !== null && $customer_ean !== '') {
// EAN should be digits only; sanitize to strip anything that slipped through
$recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean);
if ($recipient['ean'] !== '') {
$recipient['nemHandelType'] = 'ean';
} else {
unset($recipient['ean']);
}
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
@@ -42,6 +42,13 @@ class economic_invoice_draft
*/
protected float $conversion_rate = 1.0;
/**
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
* Defense in depth — even after sanitization, a final check catches anything that slips through.
* @var bool $preflight_enabled
*/
protected bool $preflight_enabled = true;
/**
* Construct a new Economic draft invoice object
@@ -116,9 +123,142 @@ class economic_invoice_draft
*/
public function addLines(): void
{
if ($this->preflight_enabled) {
$this->preflightValidate($this->draft_lines, null);
}
$this->flushLinesInBatches();
}
/**
* Pre-flight validation: defense in depth before sending to e-conomic.
* Validates each line against 5 rules and throws RuntimeException on the first violation.
*
* Rules (in order, per line):
* 1. description — must be non-empty after trim()
* 2. description — must be <= 250 chars
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
* 4. quantity — must be a positive number (> 0)
* 5. unitNetPrice — must be a number (>= 0)
*
* @param array<int,array<string,mixed>> $lines
* @param int|null $orderId Optional order id for log context
* @throws \RuntimeException on any rule violation
*/
public function preflightValidate(array $lines, ?int $orderId = null): void
{
foreach ($lines as $i => $line) {
if (!is_array($line)) {
$this->logAndThrow(
$i,
$orderId,
'line is not an array',
$line
);
}
// Rule 1 + 2: description
$description = $line['description'] ?? null;
if ($description === null) {
$description = '';
}
if (!is_scalar($description)) {
$description = (string)$description;
} else {
$description = (string)$description;
}
$descriptionTrimmed = trim($description);
if ($descriptionTrimmed === '') {
$this->logAndThrow(
$i,
$orderId,
'description is empty',
$description
);
}
if (mb_strlen($descriptionTrimmed) > 250) {
$this->logAndThrow(
$i,
$orderId,
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
$description
);
}
// Rule 3: productNumber (only if present in product.productNumber)
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
$productNumber = $line['product']['productNumber'];
if ($productNumber === null) {
$productNumber = '';
} else {
$productNumber = (string)$productNumber;
}
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
$this->logAndThrow(
$i,
$orderId,
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
$productNumber
);
}
}
// Rule 4: quantity — only required if present in the line (text lines omit it)
if (array_key_exists('quantity', $line)) {
$quantity = $line['quantity'];
if (!is_numeric($quantity) || (float)$quantity <= 0) {
$this->logAndThrow(
$i,
$orderId,
'quantity is not a positive number',
$quantity
);
}
}
// Rule 5: unitNetPrice — only required if present in the line
if (array_key_exists('unitNetPrice', $line)) {
$unitNetPrice = $line['unitNetPrice'];
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
$this->logAndThrow(
$i,
$orderId,
'unitNetPrice is not a number >= 0',
$unitNetPrice
);
}
}
}
}
/**
* Log the offending line and throw a RuntimeException.
*/
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
{
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
if ($valueTruncated === false) {
$valueTruncated = '[unserializable]';
}
if (mb_strlen($valueTruncated) > 200) {
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
}
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
error_log(sprintf(
'[preflight] validation failed: %s | line=%d | %s | value=%s',
$rule,
$lineIndex,
$orderContext,
$valueTruncated
));
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
throw new \RuntimeException(sprintf(
'Preflight validation failed for line %d: %s%s',
$lineIndex,
$rule,
$orderPart
));
}
/**
* Add queued draft lines using chunked requests.
*
@@ -185,45 +325,55 @@ class economic_invoice_draft
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
// Parse the date of the transaction.
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
// Sanitize the department name (could contain "/" or other chars)
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
// Add the text line to the draft invoice
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a PO number, add it to the invoice
if ($order->po->value() !== '') {
self::addTextLine('PO: ' . $order->po->value());
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
}
// If there's a reference, add it to the invoice
if ($order->reference->value() !== '') {
$reference_value = $order->reference->value();
if ($reference_value !== '') {
self::addTextLine('Reference:');
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
if (str_contains($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->reference->value());
self::addTextLine('# ' . $reference_sanitized);
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '')
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
if ($order->reg_2->value() !== '')
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
if ($order->reg_3->value() !== '')
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
}
// Add the line to the invoice (If there's any registration numbers)
if ($line_reg !== '')
self::addTextLine($line_reg);
// If there's a note, add it to the invoice
if ($order->notes->value() !== '') {
$notes_value = $order->notes->value();
if ($notes_value !== '') {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->notes->value(), "\n")) {
foreach ( explode("\n", $order->notes->value()) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
if (str_contains($notes_sanitized, "\n")) {
foreach ( explode("\n", $notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->notes->value());
self::addTextLine('# ' . $notes_sanitized);
}
}
}
@@ -236,11 +386,26 @@ class economic_invoice_draft
*/
public function addTextLine(string $text): void
{
// Defense in depth: sanitize ALL text lines at insertion time.
// This catches anything that wasn't pre-sanitized at the call site.
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
if ($sanitized === '') {
return; // Skip empty/whitespace-only lines
}
$this->draft_lines[] = [
'description' => $text
'description' => $sanitized
];
}
/**
* Get the current draft lines (read-only view).
* Used by integration tests; production code uses addLines() to send.
*/
public function getDraftLines(): array
{
return $this->draft_lines;
}
/**
* Add an order to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
@@ -249,7 +414,7 @@ class economic_invoice_draft
* @throws Exception if the order is not found
* @throws Exception if the order is not valid
*/
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
{
// Get the order items
$order_items = $order->getOrderItems($order->id);
@@ -268,18 +433,25 @@ class economic_invoice_draft
});
// Define the total discount applied to the order
$total_discount = 0;
// Normalize the customer discount percentage (clamp to 0..100)
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
// Force itemized discount mode when the customer has a global e-conomic discount
// so the discount is applied at the line level (e-conomic line API requires per-line
// discountPercentage; an aggregate TotDiscount line would be ignored when the
// customer does not have a per-line discount configured for the customer).
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
// Loop through the order items
foreach ( $order_items as $order_item ) {
if ($this->shouldSkipOrderItemLine($order_item)) {
continue;
}
// Add the order item to the draft invoice
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
// Add the line discount to the total discount
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
}
// If the total discount is greater than 0, add it to the invoice
if (!$use_itemized_discounts && $total_discount > 0) {
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
// Add the discount to the invoice
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
}
@@ -295,7 +467,7 @@ class economic_invoice_draft
* @throws Exception if the order item is not found
* @throws Exception if the order item is not valid
*/
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
{
// Check if the order item is valid
if (!isset($order_item['id'])) {
@@ -309,9 +481,18 @@ class economic_invoice_draft
// Get the dimension id
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$pricing = self::resolveOrderItemInvoicePricing($order_item);
$discount_percentage = $use_itemized_discount
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
// is applied at the line level. Combined with per-item discounts using max() so the
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
// an already-discounted per-item price.
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
$itemized_discount_percentage = $use_itemized_discount
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
: 0;
: 0.0;
$discount_percentage = (float)max(
$itemized_discount_percentage,
(float)$customer_discount_percentage
);
// Add the order item to the draft invoice
self::addProductLine(
(string)$order_item['product']['economic_product_id'],
@@ -341,26 +522,28 @@ class economic_invoice_draft
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
self::addTextLine('Reference:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['reference'], "\n")) {
foreach ( explode("\n", $order_item['reference']) as $line ) {
// Sanitize the reference (handles "/" → "-" per TRU-188)
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
if (str_contains($item_reference_sanitized, "\n")) {
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['reference']);
self::addTextLine('# ' . $item_reference_sanitized);
}
}
// If there's a note, add it to the line
if (!empty($order_item['notes'])) {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['notes'], "\n")) {
foreach ( explode("\n", $order_item['notes']) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
if (str_contains($item_notes_sanitized, "\n")) {
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['notes']);
self::addTextLine('# ' . $item_notes_sanitized);
}
}
@@ -470,6 +653,13 @@ class economic_invoice_draft
*/
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
{
// Sanitize product identifier and description (defense in depth — also done at addLines())
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
// Skip if sanitization removed everything
if ($productNumber === '' || $description === '') {
return;
}
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
// Add a line to the invoice
$line = [
@@ -32,7 +32,7 @@ class email_template_stripe_invoice
<!-- Email template -->
<p>Kære <?= $this->name ?>,</p>
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig en faktura på Stripe.
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig et betalingslink til din faktura.
Du kan betale fakturaen ved at klikke på linket nedenfor:</p>
<p><a href="<?= $this->stripe_payment_link ?>">Betal faktura for ordre <?= $this->order_id ?></a></p>
<!-- End of the email template -->
+10 -6
View File
@@ -205,10 +205,12 @@ class bookings_o extends db
$sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql);
if ($db->num_rows($result) === 0) {
// Send a department webhook if the booking is new
// Send a department webhook if the booking is new.
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = 0) so only pickup bookings post to Slack.
$slack = new slack();
try {
$slack->send_department_booking_notification($department, $slack->format_new_booking(
$slack->send_new_booking_notification(
$id,
$customer_number,
$wash_type,
@@ -219,12 +221,12 @@ class bookings_o extends db
$washCertificateEmail,
$date,
$department,
$pickup_bool,
(bool)$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
));
);
} catch (Exception $e) {
// Log the error
$logs = new logs_o();
@@ -313,11 +315,13 @@ class bookings_o extends db
!$deliverSlack // Only send email if slack is not available
);
// Check if the department has a slack webhook
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = false) so only pickup bookings post to Slack.
if ($deliverSlack) {
// Send a notification to the department
$slack = new slack();
try {
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
$slack->send_new_booking_notification(
$this->id,
$customer_array['customer_number'],
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
@@ -333,7 +337,7 @@ class bookings_o extends db
$this->washCertificateStatus->value(),
$this->washCertificateUrl->value(),
$this->status->value()
));
);
} catch (Exception $e) {
// Previously this bare call would crash the entire
// notifyNewBooking() flow if Slack returned non-2xx, so
@@ -725,6 +725,52 @@ class collected_order_invoices_o extends db
return false;
}
/**
* Resolve the e-conomic customer discount percentage that should be applied at the
* line level when building the invoice draft. Caches via Redis to avoid hammering
* the e-conomic templates endpoint on every draft sync.
*/
private static function resolveCustomerDiscountPercentageForDraft(int $customer_number): int
{
if ($customer_number <= 0) {
return 0;
}
$user = (new users_o())->getUserByCustomerNumber($customer_number);
$userId = (int)$user->id;
if ($userId > 0 && defined('redis')) {
try {
$cached = constant('redis')->get_economic_customer_discount_percentage($userId);
if ($cached !== null) {
return max(0, min(100, (int)$cached));
}
} catch (\Throwable $e) {
// Fall through to the live lookup.
}
}
try {
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customer_number);
} catch (\Throwable $e) {
error_log(sprintf(
'[collected_order_invoices_o] Failed to resolve e-conomic customer discount for customer %d: %s',
$customer_number,
$e->getMessage()
));
return 0;
}
if ($userId > 0 && defined('redis')) {
try {
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
} catch (\Throwable $e) {
// Cache failures are non-fatal.
}
}
return max(0, min(100, $discount));
}
/**
* Require the invoice draft to not already exist
* @throws Exception If the request was not successful
@@ -964,7 +1010,18 @@ class collected_order_invoices_o extends db
break;
}
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
// Look up the customer-level e-conomic discount (e.g. bug #11 customer 35131752
// "kd" 15%). This is applied at the line level so the draft invoice carries the
// discount percentage that e-conomic expects for the customer.
$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft((int)$this->customer_number->value());
$metrics = (new economic())->invoices->draft->add_orders(
$draft_id,
$order_objects,
$currency,
500,
$use_itemized_discounts,
$customer_discount_percentage
);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
+62
View File
@@ -906,6 +906,68 @@ class orders_o extends db
return (bool)$count;
}
/**
* Get the timestamp of the most recent completed wash for a license plate.
* Used by the front page to show the "last washed" hint when a plate is
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
*
* Only orders that have at least one non-deleted order item are
* considered (mirrors the contract used by customer_vehicles_o::
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
*
* @param string $reg_1 The license plate to look up
* @return string|null MySQL datetime string of the most recent qualifying
* order's `created_at`, or null when the plate has
* never been washed.
*/
public function getLastWashTimestampForPlate(string $reg_1): ?string
{
$normalized_reg_1 = trim($reg_1);
if ($normalized_reg_1 === '') {
return null;
}
$orders = self::getFieldsWhere([
'reg_1' => $normalized_reg_1,
'deleted_at' => null,
], [
'id',
]);
// Walk the orders newest-first and return the first one that actually
// has at least one non-deleted order item.
$candidate_ids = array_reverse(array_map(static function ($row) {
return (int)($row['id'] ?? 0);
}, $orders));
foreach ($candidate_ids as $order_id) {
if ($order_id <= 0) {
continue;
}
$has_items = (new order_items_o())->getFieldsWhere([
'order_id' => $order_id,
'deleted_at' => null,
], ['id']);
if (count($has_items) === 0) {
continue;
}
$details = self::getFieldsWhere([
'id' => $order_id,
'deleted_at' => null,
], ['created_at']);
$created_at = $details[0]['created_at'] ?? null;
if (is_string($created_at) && $created_at !== '') {
return $created_at;
}
}
return null;
}
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
{
// Get the fixed pricing transactions for a customer
+38 -5
View File
@@ -10344,8 +10344,11 @@ paths:
post:
tags:
- Modules
summary: Create Stripe invoice
description: Create an invoice in Stripe
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
description: |
Retired in favour of in-store card payments. Always returns HTTP 410
with `code: stripe_email_payment_disabled` so the POS can fall back
to the standard card-payment flow.
operationId: createStripeInvoice
requestBody:
required: false
@@ -10353,11 +10356,41 @@ paths:
application/json:
schema: {}
responses:
'201':
description: Stripe invoice created successfully
'410':
description: Direct Stripe payment links by email are no longer available
content:
application/json:
schema: {}
schema:
type: object
properties:
code:
type: string
example: stripe_email_payment_disabled
message:
type: string
delete:
tags:
- Modules
summary: Cancel/clean up a legacy Stripe hosted invoice
description: |
Void a pre-existing Stripe hosted invoice that was created before
direct payment links were retired from POS (TRU-74 / DRIFT 13).
Card payments created via the new flow are not affected and use
the standard payment-intent lifecycle instead.
operationId: cancelLegacyStripeInvoice
parameters:
- name: order_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: Legacy Stripe hosted invoice was voided
content:
application/json:
schema:
type: object
/modules/stripe/terminal/readers:
get:
@@ -0,0 +1,32 @@
[Unit]
Description=Truck Wash API cron worker (long-running scheduler)
After=network-online.target php8.2-fpm.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/copenhagentruckwash-api/services/nginx/app
ExecStart=/usr/bin/php /opt/copenhagentruckwash-api/services/nginx/app/index.php run cron-worker
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cron-worker
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/copenhagentruckwash-api/services/php/logs
# Resource limits
LimitNOFILE=65536
MemoryMax=512M
[Install]
WantedBy=multi-user.target
@@ -24,6 +24,9 @@ use objects\products_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class InvoicingPeriodRoute
{
use route_t;
@@ -1412,6 +1415,7 @@ class InvoicingPeriodRoute
public function run(): void
{
$this->post('/superuser/invoicing/period/object-tree/canary', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/object-tree/canary');
global $response;
$this->requirePermission('superuser');
self::requireParameters(['enabled']);
@@ -1463,6 +1467,7 @@ class InvoicingPeriodRoute
]);
$this->get('/superuser/invoicing/period', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1507,6 +1512,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/tree', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/tree');
global $response;
$this->requirePermission('superuser_invoicing_period');
$user = (new authentication())->get_user();
@@ -1546,6 +1552,7 @@ class InvoicingPeriodRoute
);
$this->post('/superuser/invoicing/period/flags', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags');
global $response;
$this->requirePermission('add_invoice_period_flag');
$user = (new authentication())->get_user();
@@ -1571,6 +1578,7 @@ class InvoicingPeriodRoute
);
$this->patch('/superuser/invoicing/period/flags/{id}/status', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/{id}/status');
global $response;
$this->requirePermission('update_invoice_period_flag_status');
$user = (new authentication())->get_user();
@@ -1599,6 +1607,7 @@ class InvoicingPeriodRoute
);
$this->post('/superuser/invoicing/period/flags/automatic/status', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/automatic/status');
global $response;
$this->requirePermission('update_invoice_period_flag_status');
$user = (new authentication())->get_user();
@@ -1625,6 +1634,7 @@ class InvoicingPeriodRoute
$this->get('/superuser/invoicing/period/distribution/all', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/all');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1647,6 +1657,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/fixed-pricing', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/fixed-pricing');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1664,6 +1675,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1681,6 +1693,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/all', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/all');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1699,6 +1712,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/fixed-pricing');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1717,6 +1731,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/wash-subscriptions');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1735,6 +1750,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/customer-prices');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1753,6 +1769,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/booked-department-75');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1771,6 +1788,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/customers/pricing-history', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/customers/pricing-history');
global $response;
$this->requirePermission('superuser_customer_pricing_history_v2');
self::requireParameters(['customer_number']);
@@ -1832,6 +1850,7 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions/historical');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -7,6 +7,9 @@ use classes\account_deletion_service;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class accountDeletionRoute
{
use route_t;
@@ -14,6 +17,7 @@ class accountDeletionRoute
public function run(): void
{
$this->get('/account/deletion', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/account/deletion');
global $response;
try {
if (!account_deletion_service::apiEnabled()) {
@@ -31,6 +35,7 @@ class accountDeletionRoute
});
$this->post('/account/deletion', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/deletion');
global $response;
try {
if (!account_deletion_service::apiEnabled()) {
+4
View File
@@ -7,6 +7,9 @@ use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
@@ -26,6 +29,7 @@ class adminRoute
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/admin/schema-check');
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
@@ -6,6 +6,9 @@ use classes\attachment_store;
use classes\response;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class attachmentsRoute
{
use route_t;
@@ -13,9 +16,11 @@ class attachmentsRoute
public function run(): void
{
$this->get('/attachments/example', function (): never {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/attachments/example');
throw new \Exception('EXAMPLE ROUTE, SHOULD BE IMPLEMENTED IN THE INDIVIDUAL OBJECT ROUTES');
});
$this->post('/attachments/upload', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/attachments/upload');
global $response;
self::requireParameters(['base64_image']);
$base64_image = self::getParameter('base64_image');
@@ -28,6 +28,9 @@ use Throwable;
use traits\bird_route_helpers_t;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/**
* Narrow integration boundary between Bird and Pleno Control Plane.
*
@@ -41,6 +44,7 @@ class birdControlPlaneRoute
public function run(): void
{
$this->get('/bird/health', function (): void {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/health');
global $response;
$this->requirePermission('modules_bird_health_read');
@@ -6,6 +6,9 @@ use classes\bird;
use traits\bird_route_helpers_t;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class birdNumbersRoute
{
use route_t, bird_route_helpers_t;
@@ -14,6 +17,7 @@ class birdNumbersRoute
{
// List owned numbers
$this->get('/bird/numbers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/numbers');
global $response;
// Permission: list numbers via Bird
$this->requirePermission('modules_bird_numbers_list');
@@ -23,6 +23,8 @@ use bird\helpers\bird_voice_recording_update_payload;
use bird\helpers\bird_voice_recordings_create_payload;
use bird\helpers\bird_voice_recordings_list_query_payload;
use bird\helpers\bird_voice_say_payload;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use bird\helpers\bird_voice_test_outbound_payload;
use bird\helpers\bird_voice_update_call_payload;
use classes\bird;
@@ -37,7 +39,10 @@ class birdVoiceCallsRoute
public function run(): void
{
// List workspace call log
$this->get('/bird/voice/calls/log', function () {
// TRU-149: scope check added to satisfy scope middleware contract
// (every protected route must have at least one requireScope call).
$this->get('/bird/voice/calls/log', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/calls/log');
global $response;
$this->requirePermission('modules_bird_voice_calls_log_list');
@@ -16,6 +16,8 @@ use bird\helpers\bird_flash_list_query_payload;
use bird\helpers\bird_request_schemas;
use classes\bird;
use traits\bird_route_helpers_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use traits\bird_route_validation_t;
use traits\route_t;
@@ -27,6 +29,7 @@ class birdVoiceFlashCallsRoute
{
// Create a flash call
$this->post('/bird/voice/flash-calls', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/flash-calls');
global $response;
$this->requirePermission('modules_bird_voice_flash_calls_create');
@@ -12,6 +12,9 @@ use objects\logs_o;
use objects\order_bookings_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class bookingsRoute
{
use route_t;
@@ -20,6 +23,7 @@ class bookingsRoute
{
/** All bookings */
$this->get('/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings');
// Require the user to be logged in
global
/** @var response $response */
@@ -93,6 +97,7 @@ class bookingsRoute
);
/** Own bookings */
$this->get('/user/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/user/bookings');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -136,6 +141,7 @@ class bookingsRoute
);
$this->put('/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/bookings');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -204,6 +210,7 @@ class bookingsRoute
);
// Synchronize booking from the external system
$this->post('/admin/bookings/sync', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/sync');
// Require the user to be logged in
global $response;
$this->requirePermission('sync_bookings');
@@ -256,6 +263,7 @@ class bookingsRoute
// Get a departments unfulfilled bookings (count) for the day
$this->get('/admin/bookings/department/count', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/admin/bookings/department/count');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -296,6 +304,7 @@ class bookingsRoute
);
$this->post('/user/bookings/washcertificate/download', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/washcertificate/download');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -354,6 +363,7 @@ class bookingsRoute
);
$this->get('/bookings/download_pdf', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings/download_pdf');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -416,6 +426,7 @@ class bookingsRoute
);
$this->post('/admin/bookings/delete', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/delete');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -449,6 +460,7 @@ class bookingsRoute
);
$this->post('/superuser/bookings/sync/all', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/superuser/bookings/sync/all');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -474,6 +486,7 @@ class bookingsRoute
);
$this->post('/admin/bookings/completeWashWithoutWashCertificate', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/completeWashWithoutWashCertificate');
global /** @var response $response */
$response;
$response->error('Booking completion must be completed through POS desktop or mobile steps.', 410);
@@ -484,6 +497,7 @@ class bookingsRoute
);
$this->post('/user/bookings/delete', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/delete');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -7,6 +7,9 @@ use objects\categories_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class categoriesRoute
{
use route_t;
@@ -14,6 +17,7 @@ class categoriesRoute
public function run(): void
{
$this->get('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/categories');
// Require the user to be logged in
global $response;
@@ -59,6 +63,7 @@ class categoriesRoute
);
$this->post('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
// Require the user to be logged in
global $response;
@@ -96,6 +101,7 @@ class categoriesRoute
);
$this->put('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
// Require the user to be logged in
global $response;
+10
View File
@@ -10,6 +10,9 @@ use objects\logs_o;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class cronRoute
{
use route_t;
@@ -17,6 +20,7 @@ class cronRoute
public function run(): void
{
$this->get('/superuser/cron', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -26,6 +30,7 @@ class cronRoute
]);
$this->get('/superuser/cron/runs', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/runs');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -39,6 +44,7 @@ class cronRoute
]);
$this->get('/superuser/cron/workers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/workers');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -64,6 +70,7 @@ class cronRoute
]);
$this->post('/superuser/cron/workers/deploy', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/workers/deploy');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
@@ -84,6 +91,7 @@ class cronRoute
]);
$this->post('/superuser/cron/run', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/run');
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
@@ -109,6 +117,7 @@ class cronRoute
]);
$this->patch('/superuser/cron/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/config');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
@@ -137,6 +146,7 @@ class cronRoute
]);
$this->post('/superuser/cron', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron');
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
@@ -8,6 +8,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerAttributes
{
use route_t;
@@ -15,6 +18,7 @@ class customerAttributes
public function run(): void
{
$this->get('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/attributes');
// Require the user to be logged in
global $response;
// Get the user object
@@ -62,6 +66,7 @@ class customerAttributes
);
$this->post('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_attribute');
@@ -100,6 +105,7 @@ class customerAttributes
);
$this->delete('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_attribute');
@@ -7,6 +7,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerCodeDepartmentRoute
{
use route_t;
@@ -14,6 +17,7 @@ class customerCodeDepartmentRoute
public function run(): void
{
$this->get('/admin/customer/code', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/admin/customer/code');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_code');
@@ -50,6 +54,7 @@ class customerCodeDepartmentRoute
);
$this->post('/admin/customer/code', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/admin/customer/code');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_code');
@@ -9,6 +9,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerDefaultDepartmentRoute
{
use route_t;
@@ -16,6 +19,7 @@ class customerDefaultDepartmentRoute
public function run(): void
{
$this->get('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_default_department');
@@ -57,6 +61,7 @@ class customerDefaultDepartmentRoute
);
$this->post('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_default_department');
@@ -113,6 +118,7 @@ class customerDefaultDepartmentRoute
);
$this->delete('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_default_department');
@@ -9,6 +9,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerFixedPricingRoute
{
use route_t;
@@ -16,6 +19,7 @@ class customerFixedPricingRoute
public function run(): void
{
$this->get('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_fixed_pricing');
@@ -57,6 +61,7 @@ class customerFixedPricingRoute
]
);
$this->post('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_fixed_pricing');
@@ -135,6 +140,7 @@ class customerFixedPricingRoute
);
$this->delete('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_fixed_pricing');
@@ -8,6 +8,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerNotes
{
use route_t;
@@ -15,6 +18,7 @@ class customerNotes
public function run(): void
{
$this->get('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('list_customer_notes');
@@ -52,6 +56,7 @@ class customerNotes
);
$this->post('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_note');
@@ -92,6 +97,7 @@ class customerNotes
);
$this->delete('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_note');
@@ -9,6 +9,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerSearchRoute
{
use route_t;
@@ -17,6 +20,7 @@ class customerSearchRoute
{
//TODO: Remove this, this is deprecated in favor of the new search endpoint
$this->post('/customers/search', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/search');
// Require the user to be logged in
global $response;
$this->requirePermission('search_customers');
@@ -146,6 +150,7 @@ class customerSearchRoute
);
$this->post('/customers/import', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/import');
global $response;
$this->requirePermission('add_user');
@@ -11,6 +11,9 @@ use objects\departments_o;
use objects\product_options_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerTimeBookingsRoute
{
use route_t;
@@ -48,6 +51,7 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Departments -> GET */
$this->get('/department/timebookings/departments/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/departments/public');
global $response;
$departments = new departments_o();
@@ -97,6 +101,7 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Opening Hours -> GET */
$this->get('/department/timebookings/opening-hours/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours/public');
global $response;
$this->requirePublicTimeBookingsDepartment();
@@ -136,6 +141,7 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Types -> GET */
$this->get('/department/timebookings/types/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types/public');
global $response;
$this->requirePublicTimeBookingsDepartment();
@@ -170,6 +176,7 @@ class customerTimeBookingsRoute
);
/** Guest Time Bookings -> Entries -> GET */
$this->get('/department/timebookings/entries/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries/public');
/**
* @example Usage of this endpoint:
* GET /department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
@@ -205,6 +212,7 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Entries -> Add */
$this->post('/department/timebookings/entries/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries/public');
global $response;
self::requireParameters(['department', 'type', 'start']);
$department = $this->requirePublicTimeBookingsDepartment('department');
@@ -19,6 +19,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentDailyReportsRoute
{
use route_t;
@@ -28,6 +31,7 @@ class departmentDailyReportsRoute
public function run(): void
{
$this->get('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -84,6 +88,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/get', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/get');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -157,6 +162,7 @@ class departmentDailyReportsRoute
);
$this->post('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('create_department_daily_reports');
@@ -261,6 +267,7 @@ class departmentDailyReportsRoute
);
$this->post('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('create_department_daily_report_complaints');
@@ -360,6 +367,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/complaints/customers', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints/customers');
global $response;
$user = (new authentication())->get_user();
@@ -470,6 +478,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('list_department_daily_report_complaints');
@@ -531,6 +540,7 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('edit_department_daily_report_complaints');
@@ -664,6 +674,7 @@ class departmentDailyReportsRoute
);
$this->delete('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('delete_department_daily_report_complaints');
@@ -704,6 +715,7 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('update_department_daily_reports');
@@ -795,6 +807,7 @@ class departmentDailyReportsRoute
);
$this->get('/superuser/departments/{id}/overview', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/departments/{id}/overview');
global $response;
$this->requirePermission('superuser_fetch_department');
@@ -840,6 +853,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/overview', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/overview');
global $response;
$this->requirePermission('list_department_daily_reports');
$this->requirePermission('list_bookings');
@@ -886,6 +900,7 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports/product-targets', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/product-targets');
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
@@ -948,6 +963,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/product-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/product-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1057,6 +1073,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/transaction-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/transaction-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1153,6 +1170,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/outside-hours-trend', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/outside-hours-trend');
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1200,6 +1218,7 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/bookings-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/bookings-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_bookings');
@@ -12,6 +12,9 @@ use objects\departments_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentGatesRelaysRoute
{
use route_t;
@@ -19,6 +22,7 @@ class departmentGatesRelaysRoute
public function run(): void
{
$this->get('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('list_department_gates');
$user = (new authentication())->get_user();
@@ -56,6 +60,7 @@ class departmentGatesRelaysRoute
]);
$this->post('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('add_department_gate');
$user = (new authentication())->get_user();
@@ -99,6 +104,7 @@ class departmentGatesRelaysRoute
]);
$this->put('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('update_department_gate');
$user = (new authentication())->get_user();
@@ -157,6 +163,7 @@ class departmentGatesRelaysRoute
]);
$this->delete('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('delete_department_gate');
$user = (new authentication())->get_user();
@@ -183,6 +190,7 @@ class departmentGatesRelaysRoute
]);
$this->get('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('list_department_relays');
$user = (new authentication())->get_user();
@@ -220,6 +228,7 @@ class departmentGatesRelaysRoute
]);
$this->post('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('add_department_relay');
$user = (new authentication())->get_user();
@@ -262,6 +271,7 @@ class departmentGatesRelaysRoute
]);
$this->put('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('update_department_relay');
$user = (new authentication())->get_user();
@@ -314,6 +324,7 @@ class departmentGatesRelaysRoute
]);
$this->delete('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('delete_department_relay');
$user = (new authentication())->get_user();
@@ -14,6 +14,9 @@ use objects\departments_o;
use objects\department_goals_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentGoalsRoute
{
use route_t;
@@ -22,6 +25,7 @@ class departmentGoalsRoute
{
// List or get single department goal(s)
$this->get('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_list');
@@ -83,6 +87,7 @@ class departmentGoalsRoute
// Create a new department goal
$this->post('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_create');
@@ -137,6 +142,7 @@ class departmentGoalsRoute
// Update an existing department goal
$this->put('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_update');
@@ -236,6 +242,7 @@ class departmentGoalsRoute
// Delete a department goal (soft delete if supported)
$this->delete('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_delete');
@@ -275,6 +282,7 @@ class departmentGoalsRoute
// Send a test progress alert for a department goal
$this->post('/goals/department/progress-alert/test', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department/progress-alert/test');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_progress_alert_test');
@@ -14,6 +14,9 @@ use objects\department_selfserve_tasks_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentLanesRoute
{
use route_t;
@@ -23,6 +26,7 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/status-toggles');
global $response;
$this->requirePermission('list_department_lanes');
@@ -52,6 +56,7 @@ class departmentLanesRoute
);
$this->get('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -124,6 +129,7 @@ class departmentLanesRoute
);
$this->get('/department/lanes/relay-options', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/relay-options');
global $response;
$this->requirePermission('list_department_lanes');
@@ -183,6 +189,7 @@ class departmentLanesRoute
* Response: image/png
*/
$this->get('/department/lanes/dynamic-image', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/dynamic-image');
global $response;
// Reuse listing permission; viewing image is tied to lane visibility
// Authenticated user and department access validation
@@ -335,6 +342,7 @@ class departmentLanesRoute
]);
$this->post('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -400,6 +408,7 @@ class departmentLanesRoute
);
$this->put('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -9,6 +9,9 @@ use objects\department_notification_sms_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentNotificationSmsRoute
{
use route_t;
@@ -22,6 +25,7 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Get */
$this->get('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_get');
$user = (new authentication())->get_user();
@@ -60,6 +64,7 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Add */
$this->post('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_add');
$user = (new authentication())->get_user();
@@ -103,6 +108,7 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Update */
$this->put('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_update');
$user = (new authentication())->get_user();
@@ -157,6 +163,7 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Delete */
$this->delete('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_delete');
$user = (new authentication())->get_user();
@@ -13,6 +13,9 @@ use objects\department_selfserve_condition_rules_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConditionRulesRoute
{
use route_t;
@@ -23,6 +26,7 @@ class departmentSelfserveConditionRulesRoute
* List department self-serve condition rules
*/
$this->get('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('list_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -97,6 +101,7 @@ class departmentSelfserveConditionRulesRoute
* Add a department self-serve condition rule
*/
$this->post('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('add_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -149,6 +154,7 @@ class departmentSelfserveConditionRulesRoute
* Update a department self-serve condition rule
*/
$this->put('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('update_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -221,6 +227,7 @@ class departmentSelfserveConditionRulesRoute
* Delete a department self-serve condition rule
*/
$this->delete('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('delete_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -12,6 +12,9 @@ use objects\department_selfserve_conditions_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConditionsRoute
{
use route_t;
@@ -22,6 +25,7 @@ class departmentSelfserveConditionsRoute
* List department self-serve conditions
*/
$this->get('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/conditions');
global $response;
$this->requirePermission('list_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -97,6 +101,7 @@ class departmentSelfserveConditionsRoute
* Add a department self-serve condition
*/
$this->post('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('add_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -156,6 +161,7 @@ class departmentSelfserveConditionsRoute
* Update a department self-serve condition
*/
$this->put('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('update_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -220,6 +226,7 @@ class departmentSelfserveConditionsRoute
* Delete a department self-serve condition
*/
$this->delete('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('delete_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -10,6 +10,9 @@ use modules\selfserve\classes\selfserve_config_versioning;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConfigVersionsRoute
{
use route_t;
@@ -17,6 +20,7 @@ class departmentSelfserveConfigVersionsRoute
public function run(): void
{
$this->get('/department/selfserve/config/versions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/versions');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -41,6 +45,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->get('/department/selfserve/config/history', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/history');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -63,6 +68,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->get('/department/selfserve/config/active', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/active');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -98,6 +104,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/draft', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/draft');
global $response;
$this->requirePermission('edit_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -122,6 +129,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/validate', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/validate');
global $response;
$this->requirePermission('edit_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -142,6 +150,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/publish', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/publish');
global $response;
$this->requirePermission('publish_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -166,6 +175,7 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/rollback', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/rollback');
global $response;
$this->requirePermission('rollback_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -10,6 +10,9 @@ use objects\logs_o;
use objects\selfserve_machine_types_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveMachineTypesRoute
{
use route_t;
@@ -17,6 +20,7 @@ class departmentSelfserveMachineTypesRoute
public function run(): void
{
$this->get('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('list_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -45,6 +49,7 @@ class departmentSelfserveMachineTypesRoute
]);
$this->post('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('add_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -70,6 +75,7 @@ class departmentSelfserveMachineTypesRoute
]);
$this->put('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('update_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -102,6 +108,7 @@ class departmentSelfserveMachineTypesRoute
]);
$this->delete('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('delete_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -12,6 +12,9 @@ use objects\department_selfserve_questions_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveQuestionsRoute
{
use route_t;
@@ -22,6 +25,7 @@ class departmentSelfserveQuestionsRoute
* List department self-serve questions
*/
$this->get('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/questions');
global $response;
$this->requirePermission('list_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -93,6 +97,7 @@ class departmentSelfserveQuestionsRoute
* Add a department self-serve question
*/
$this->post('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('add_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -147,6 +152,7 @@ class departmentSelfserveQuestionsRoute
* Update a department self-serve question
*/
$this->put('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('edit_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -208,6 +214,7 @@ class departmentSelfserveQuestionsRoute
* Delete a department self-serve question
*/
$this->delete('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('delete_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -9,6 +9,9 @@ use modules\selfserve\classes\selfserve_wash_flow;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveStudioRoute
{
use route_t;
@@ -16,6 +19,7 @@ class departmentSelfserveStudioRoute
public function run(): void
{
$this->get('/department/selfserve/studio/graph', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/studio/graph');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -31,6 +35,7 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/graph', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/graph');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -51,6 +56,7 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/layout', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/layout');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'layout']);
@@ -70,6 +76,7 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/virtual-hardware', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/virtual-hardware');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'operation']);
@@ -94,6 +101,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/validate', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/validate');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -108,6 +116,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/simulate', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/simulate');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department', 'lane_id', 'reg']);
@@ -133,6 +142,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes/stream');
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
@@ -173,6 +183,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -196,6 +207,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-confirmations');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
@@ -219,6 +231,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/publish', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/publish');
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -240,6 +253,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/rollback', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/rollback');
global $response;
$user = $this->requireStudioUser('rollback_department_selfserve_config_versions');
self::requireParameters(['department', 'target_version_id']);
@@ -260,6 +274,7 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/gateway-action', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/gateway-action');
global $response;
$user = $this->requireStudioUser('modules_shelly_config');
self::requireParameters(['department', 'gateway_id', 'action']);
@@ -17,6 +17,9 @@ use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_task_gate_type;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveTasksRoute
{
use route_t;
@@ -27,6 +30,7 @@ class departmentSelfserveTasksRoute
* List department self-serve tasks
*/
$this->get('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks');
global $response;
$this->requirePermission('list_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -112,6 +116,7 @@ class departmentSelfserveTasksRoute
* Add a department self-serve task
*/
$this->post('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('add_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -271,6 +276,7 @@ class departmentSelfserveTasksRoute
* Update a department self-serve task
*/
$this->put('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('edit_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -437,6 +443,7 @@ class departmentSelfserveTasksRoute
* Delete a department self-serve task
*/
$this->delete('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('delete_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -470,6 +477,7 @@ class departmentSelfserveTasksRoute
* Download an attachment for a department self-serve task
*/
$this->get('/department/selfserve/tasks/attachments/download', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments/download');
global $response;
$this->requirePermission('download_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -520,6 +528,7 @@ class departmentSelfserveTasksRoute
* List attachments for a department self-serve task
*/
$this->get('/department/selfserve/tasks/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments');
global $response;
$this->requirePermission('list_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -557,6 +566,7 @@ class departmentSelfserveTasksRoute
* Upload an attachment for a department self-serve task
*/
$this->post('/department/selfserve/tasks/attachments/upload', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments/upload');
global $response;
$this->requirePermission('add_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -603,6 +613,7 @@ class departmentSelfserveTasksRoute
* Delete an attachment for a department self-serve task
*/
$this->delete('/department/selfserve/tasks/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments');
global $response;
$this->requirePermission('delete_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -19,6 +19,9 @@ use objects\logs_o;
use objects\selfserve_wash_sessions_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveVehicleConditionsRoute
{
use route_t;
@@ -29,6 +32,7 @@ class departmentSelfserveVehicleConditionsRoute
* List department self-serve vehicle conditions
*/
$this->get('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -118,6 +122,7 @@ class departmentSelfserveVehicleConditionsRoute
* Check whether self-serve is allowed for a specific vehicle and lane
*/
$this->get('/department/selfserve/vehicle/allowed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/allowed');
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
@@ -165,6 +170,7 @@ class departmentSelfserveVehicleConditionsRoute
* Get self-serve wash summary
*/
$this->get('/department/selfserve/washes/summary', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/washes/summary');
global $response;
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
@@ -249,6 +255,7 @@ class departmentSelfserveVehicleConditionsRoute
* Add a department self-serve vehicle condition
*/
$this->post('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
@@ -311,6 +318,7 @@ class departmentSelfserveVehicleConditionsRoute
* Update a department self-serve vehicle condition
*/
$this->put('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -408,6 +416,7 @@ class departmentSelfserveVehicleConditionsRoute
* Delete a department self-serve vehicle condition
*/
$this->delete('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -12,6 +12,9 @@ use objects\logs_o;
use objects\product_options_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentTimeBookingsRoute
{
use route_t;
@@ -25,6 +28,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> List */
$this->get('/department/timebookings/opening-hours', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours');
global $response;
$this->requirePermission('department_timebookings_opening_hours_get');
$user = (new authentication())->get_user();
@@ -74,6 +78,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Update */
$this->put('/department/timebookings/opening-hours', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/opening-hours');
global $response;
$this->requirePermission('department_timebookings_opening_hours_put');
$user = (new authentication())->get_user();
@@ -124,6 +129,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types */
$this->get('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_get');
$user = (new authentication())->get_user();
@@ -169,6 +175,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Add */
$this->post('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_post');
$user = (new authentication())->get_user();
@@ -204,6 +211,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Update */
$this->put('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_put');
$user = (new authentication())->get_user();
@@ -239,6 +247,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Delete */
$this->delete('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_delete');
$user = (new authentication())->get_user();
@@ -269,6 +278,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Entries */
$this->get('/department/timebookings/entries', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries');
global $response;
$this->requirePermission('department_timebookings_entries_get');
$user = (new authentication())->get_user();
@@ -324,6 +334,7 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Entries -> Add */
$this->post('/department/timebookings/entries', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries');
global $response;
$this->requirePermission('department_timebookings_entries_post');
$user = (new authentication())->get_user();
@@ -16,6 +16,9 @@ use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentsRoute
{
use route_t;
@@ -83,6 +86,7 @@ class departmentsRoute
public function run(): void
{
$this->get('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('list_departments');
@@ -173,6 +177,7 @@ class departmentsRoute
);
$this->post('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('add_department');
@@ -209,6 +214,7 @@ class departmentsRoute
);
$this->put('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('edit_department');
@@ -265,6 +271,7 @@ class departmentsRoute
);
$this->get('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/categories');
// Require the user to be logged in
global $response;
$auth = new authentication();
@@ -323,6 +330,7 @@ class departmentsRoute
);
$this->get('/departments/self-serve/enabled', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/self-serve/enabled');
// Require the user to be logged in
global $response;
$this->requirePermission('view_department_selfserve_enabled');
@@ -362,6 +370,7 @@ class departmentsRoute
);
$this->put('/departments/self-serve/enabled', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/self-serve/enabled');
// Require the user to be logged in
global $response;
$this->requirePermission('edit_department_selfserve_enabled');
@@ -413,6 +422,7 @@ class departmentsRoute
);
$this->post('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
// Require the user to be logged in
global $response;
$this->requirePermission('add_department_category');
@@ -465,6 +475,7 @@ class departmentsRoute
);
$this->delete('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_department_category');
@@ -15,6 +15,9 @@ use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicInvoiceRoute
{
use route_t;
@@ -26,6 +29,7 @@ class economicInvoiceRoute
$router, $response;
$this->post('/economic/invoice/draft/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -99,6 +103,7 @@ class economicInvoiceRoute
]);
$this->delete('/economic/invoice/draft/delete', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/delete');
global /** @var response $response */
$response;
$this->requirePermission('economic_invoice_draft_delete');
@@ -139,6 +144,7 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -223,6 +229,7 @@ class economicInvoiceRoute
]);
$this->get('/economic/invoice/draft/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/draft/export/status');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -248,6 +255,7 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/draft/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export/retry');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -282,6 +290,7 @@ class economicInvoiceRoute
]);
$this->get('/economic/invoice/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/export/status');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -307,6 +316,7 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export/retry');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -9,6 +9,9 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicLayoutsRoute
{
use route_t;
@@ -21,6 +24,7 @@ class economicLayoutsRoute
$this->get('/economic/layouts', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/layouts');
global $response;
$this->requirePermission('economic_layouts');
$user = (new authentication())->get_user();
@@ -9,6 +9,9 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicPaymentTermsRoute
{
use route_t;
@@ -21,6 +24,7 @@ class economicPaymentTermsRoute
$this->get('/economic/payment-terms', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/payment-terms');
global $response;
$this->requirePermission('economic_payment_terms');
$user = (new authentication())->get_user();
@@ -13,6 +13,8 @@ use objects\departments_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use objects\users_o;
use traits\route_t;
@@ -23,6 +25,7 @@ class exampleRoute
public function run(): void
{
$this->get('/example', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/example');
global $response;
$response->success(['message' => 'Hello World!']);
});
@@ -7,6 +7,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class intimidateRoute
{
use route_t;
@@ -14,6 +17,7 @@ class intimidateRoute
public function run(): void
{
$this->post('/su/intimidate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/su/intimidate');
// Get the post data
global $response;
// Make sure the user has the SUPERUSER_INTIMIDATE permission
@@ -12,6 +12,9 @@ use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class invoicesRoute
{
use route_t;
@@ -19,6 +22,7 @@ class invoicesRoute
public function run(): void
{
$this->get('/invoices/draft', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/draft');
// Require the user to be logged in
global $response;
$this->requirePermission('get_invoice_draft');
@@ -57,6 +61,7 @@ class invoicesRoute
);
$this->post('/invoices/draft/close', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/invoices/draft/close');
// Require the user to be logged in
global $response;
$this->requirePermission('close_invoice_draft');
@@ -94,6 +99,7 @@ class invoicesRoute
);
$this->get('/invoices/pdf', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/pdf');
// Require the user to be logged in
global $response;
$this->requirePermission('get_invoice_pdf');
@@ -8,6 +8,9 @@ use classes\limited_backoffice_login_grant_service;
use classes\limited_backoffice_service;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
class limitedBackofficeRoute
@@ -17,6 +20,7 @@ class limitedBackofficeRoute
public function run(): void
{
$this->get('/limited-backoffice/departments', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limited-backoffice/departments');
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
return $service->departmentsForUser($user);
@@ -11,6 +11,9 @@ use objects\logs_o;
use objects\plate_scanners_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class machineButtonPressRoute
{
use route_t;
@@ -18,6 +21,7 @@ class machineButtonPressRoute
public function run(): void
{
$handler = function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/relay/button/press/post');
global $response;
self::requirePlateScannerAuth();
@@ -7,6 +7,9 @@ use objects\logs_o;
use objects\module_action_logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleActionLogsRoute
{
use route_t;
@@ -15,6 +18,7 @@ class moduleActionLogsRoute
{
/** Modules > Action Logs > List */
$this->get('/modules/action-logs', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/action-logs');
global $response;
$this->requirePermission('modules_action_logs_view');
@@ -10,6 +10,9 @@ use objects\logs_o;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleBackupsRoute
{
use route_t;
@@ -21,6 +24,7 @@ class moduleBackupsRoute
$router, $response;
$this->get('/modules/backup/backups', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/backups');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
try {
@@ -38,6 +42,7 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_create');
$user_id = $this->actorUserId();
@@ -59,6 +64,7 @@ class moduleBackupsRoute
]);
$this->get('/modules/backup/jobs/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/jobs/{id}');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
$job_id = (int)$this->fromRoute('id');
@@ -72,6 +78,7 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/verify');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_verify');
try {
@@ -87,6 +94,7 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore/preview');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -102,6 +110,7 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -128,6 +137,7 @@ class moduleBackupsRoute
]);
$this->get('/modules/backup/restore-audit', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/restore-audit');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -19,6 +19,9 @@ use classes\workfeed;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleConfigRoute
{
use route_t;
@@ -32,6 +35,7 @@ class moduleConfigRoute
/** Economic config > GET */
$this->get('/economic/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/economic/config');
global $response;
$this->requirePermission('economic_config');
$user = (new authentication())->get_user();
@@ -52,6 +56,7 @@ class moduleConfigRoute
/** Economic config > POST */
$this->post('/economic/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/economic/config');
global $response;
$this->requirePermission('economic_config');
$user = (new authentication())->get_user();
@@ -72,6 +77,7 @@ class moduleConfigRoute
/** reCAPTCHA config > GET */
$this->get('/reCAPTCHA/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/reCAPTCHA/config');
global $response;
$this->requirePermission('recaptcha_config');
$user = (new authentication())->get_user();
@@ -92,6 +98,7 @@ class moduleConfigRoute
/** reCAPTCHA config > POST */
$this->post('/reCAPTCHA/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/reCAPTCHA/config');
global $response;
$this->requirePermission('recaptcha_config');
$user = (new authentication())->get_user();
@@ -112,6 +119,7 @@ class moduleConfigRoute
/** Email config > GET */
$this->get('/email/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/email/config');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -132,6 +140,7 @@ class moduleConfigRoute
/** Email config > POST */
$this->post('/email/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -152,6 +161,7 @@ class moduleConfigRoute
/** Email config > TEST */
$this->post('/email/config/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config/test');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -183,6 +193,7 @@ class moduleConfigRoute
/** Slack config > GET */
$this->get('/slack/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -203,6 +214,7 @@ class moduleConfigRoute
/** Slack config > POST */
$this->post('/slack/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -223,6 +235,7 @@ class moduleConfigRoute
/** Slack config > TEST */
$this->post('/slack/config/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/test');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -252,6 +265,7 @@ class moduleConfigRoute
/** Slack internal department goal progress config > TEST */
$this->post('/slack/config/internal-department-goal-progress/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress/test');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -281,6 +295,7 @@ class moduleConfigRoute
/** Slack internal department goal progress config > GET */
$this->get('/slack/config/internal-department-goal-progress', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config/internal-department-goal-progress');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -301,6 +316,7 @@ class moduleConfigRoute
/** Slack internal department goal progress config > POST */
$this->post('/slack/config/internal-department-goal-progress', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -334,6 +350,7 @@ class moduleConfigRoute
);
$this->get('/backups/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/backups/config');
global $response;
$this->requirePermission('backups_config');
$user = (new authentication())->get_user();
@@ -353,6 +370,7 @@ class moduleConfigRoute
);
$this->post('/backups/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/backups/config');
global $response;
$this->requirePermission('backups_config');
$user = (new authentication())->get_user();
@@ -372,6 +390,7 @@ class moduleConfigRoute
);
$this->get('/failover/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/failover/config');
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
@@ -391,6 +410,7 @@ class moduleConfigRoute
);
$this->post('/failover/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/failover/config');
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
@@ -410,6 +430,7 @@ class moduleConfigRoute
);
$this->get('/coolify/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/coolify/config');
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
@@ -429,6 +450,7 @@ class moduleConfigRoute
);
$this->post('/coolify/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/coolify/config');
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
@@ -449,6 +471,7 @@ class moduleConfigRoute
/** Bird config > GET */
$this->get('/bird/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/config');
global $response;
$this->requirePermission('modules_bird_config');
$user = (new authentication())->get_user();
@@ -469,6 +492,7 @@ class moduleConfigRoute
/** Bird config > POST */
$this->post('/bird/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/bird/config');
global $response;
$this->requirePermission('modules_bird_config');
$user = (new authentication())->get_user();
@@ -489,6 +513,7 @@ class moduleConfigRoute
/** MotorAPI config > GET */
$this->get('/motorapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/motorapi/config');
global $response;
$this->requirePermission('motorapi_config');
$user = (new authentication())->get_user();
@@ -509,6 +534,7 @@ class moduleConfigRoute
/** MotorAPI config > POST */
$this->post('/motorapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/motorapi/config');
global $response;
$this->requirePermission('motorapi_config');
$user = (new authentication())->get_user();
@@ -529,6 +555,7 @@ class moduleConfigRoute
/** Stripe config > GET */
$this->get('/stripe/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/stripe/config');
global $response;
$this->requirePermission('stripe_config');
$user = (new authentication())->get_user();
@@ -549,6 +576,7 @@ class moduleConfigRoute
/** Stripe config > POST */
$this->post('/stripe/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/stripe/config');
global $response;
$this->requirePermission('stripe_config');
$user = (new authentication())->get_user();
@@ -569,6 +597,7 @@ class moduleConfigRoute
/** FXRatesAPI config > GET */
$this->get('/fxratesapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/fxratesapi/config');
global $response;
$this->requirePermission('fxratesapi_config');
$user = (new authentication())->get_user();
@@ -588,6 +617,7 @@ class moduleConfigRoute
);
/** FXRatesAPI config > POST */
$this->post('/fxratesapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/fxratesapi/config');
global $response;
$this->requirePermission('fxratesapi_config');
$user = (new authentication())->get_user();
@@ -607,6 +637,7 @@ class moduleConfigRoute
);
/** GatewayAPI config > GET */
$this->get('/gatewayapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/gatewayapi/config');
global $response;
$this->requirePermission('gatewayapi_config');
$user = (new authentication())->get_user();
@@ -626,6 +657,7 @@ class moduleConfigRoute
);
/** GatewayAPI config > POST */
$this->post('/gatewayapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/gatewayapi/config');
global $response;
$this->requirePermission('gatewayapi_config');
$user = (new authentication())->get_user();
@@ -646,6 +678,7 @@ class moduleConfigRoute
/** WeatherAPI config > GET */
$this->get('/weatherapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/weatherapi/config');
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
@@ -665,6 +698,7 @@ class moduleConfigRoute
);
/** WeatherAPI config > POST */
$this->post('/weatherapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/weatherapi/config');
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
@@ -685,6 +719,7 @@ class moduleConfigRoute
/** n8n config > GET */
$this->get('/n8n/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/n8n/config');
global $response;
$this->requirePermission('modules_n8n_config');
$user = (new authentication())->get_user();
@@ -704,6 +739,7 @@ class moduleConfigRoute
);
/** n8n config > POST */
$this->post('/n8n/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/n8n/config');
global $response;
$this->requirePermission('modules_n8n_config');
$user = (new authentication())->get_user();
@@ -724,6 +760,7 @@ class moduleConfigRoute
/** Workfeed config > GET */
$this->get('/workfeed/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/workfeed/config');
global $response;
$this->requirePermission('modules_workfeed_config');
$user = (new authentication())->get_user();
@@ -743,6 +780,7 @@ class moduleConfigRoute
);
/** Workfeed config > POST */
$this->post('/workfeed/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/workfeed/config');
global $response;
$this->requirePermission('modules_workfeed_config');
$user = (new authentication())->get_user();
@@ -763,6 +801,7 @@ class moduleConfigRoute
/** XLVask config > GET */
$this->get('/xlvask/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/xlvask/config');
global $response;
$this->requirePermission('xlvask_config');
$user = (new authentication())->get_user();
@@ -782,6 +821,7 @@ class moduleConfigRoute
);
/** XLVask config > POST */
$this->post('/xlvask/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/xlvask/config');
global $response;
$this->requirePermission('xlvask_config');
$user = (new authentication())->get_user();
@@ -802,6 +842,7 @@ class moduleConfigRoute
/** Entra config > GET */
$this->get('/entra/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/entra/config');
global $response;
$this->requirePermission('entra_config');
$user = (new authentication())->get_user();
@@ -821,6 +862,7 @@ class moduleConfigRoute
);
/** Entra config > POST */
$this->post('/entra/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/entra/config');
global $response;
$this->requirePermission('entra_config');
$user = (new authentication())->get_user();
@@ -840,6 +882,7 @@ class moduleConfigRoute
);
/** Limble config > GET */
$this->get('/limble/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limble/config');
global $response;
$this->requirePermission('modules_limble_config');
$user = (new authentication())->get_user();
@@ -859,6 +902,7 @@ class moduleConfigRoute
);
/** Limble config > POST */
$this->post('/limble/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/limble/config');
global $response;
$this->requirePermission('modules_limble_config');
$user = (new authentication())->get_user();
@@ -878,6 +922,7 @@ class moduleConfigRoute
);
/** OcrSpace config > GET */
$this->get('/ocrspace/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/ocrspace/config');
global $response;
$this->requirePermission('modules_ocrspace_config');
$user = (new authentication())->get_user();
@@ -897,6 +942,7 @@ class moduleConfigRoute
);
/** OcrSpace config > POST */
$this->post('/ocrspace/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/ocrspace/config');
global $response;
$this->requirePermission('modules_ocrspace_config');
$user = (new authentication())->get_user();
@@ -916,6 +962,7 @@ class moduleConfigRoute
);
/** OpenAI config > GET */
$this->get('/openai/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/openai/config');
global $response;
$this->requirePermission('modules_openai_config');
$user = (new authentication())->get_user();
@@ -935,6 +982,7 @@ class moduleConfigRoute
);
/** OpenAI config > POST */
$this->post('/openai/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/openai/config');
global $response;
$this->requirePermission('modules_openai_config');
$user = (new authentication())->get_user();
@@ -954,6 +1002,7 @@ class moduleConfigRoute
);
/** LicensePlateRecognizer config > GET */
$this->get('/licenseplaterecognizer/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/licenseplaterecognizer/config');
global $response;
$this->requirePermission('modules_licenseplaterecognizer_config');
$user = (new authentication())->get_user();
@@ -973,6 +1022,7 @@ class moduleConfigRoute
);
/** LicensePlateRecognizer config > POST */
$this->post('/licenseplaterecognizer/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/licenseplaterecognizer/config');
global $response;
$this->requirePermission('modules_licenseplaterecognizer_config');
$user = (new authentication())->get_user();
@@ -992,6 +1042,7 @@ class moduleConfigRoute
);
/** Virkdata config > GET */
$this->get('/virkdata/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/virkdata/config');
global $response;
$this->requirePermission('modules_virkdata_config');
$user = (new authentication())->get_user();
@@ -1010,6 +1061,7 @@ class moduleConfigRoute
);
/** Virkdata config > POST */
$this->post('/virkdata/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/virkdata/config');
global $response;
$this->requirePermission('modules_virkdata_config');
$user = (new authentication())->get_user();
@@ -1029,6 +1081,7 @@ class moduleConfigRoute
);
/** Shelly config -> GET */
$this->get('/shelly/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/shelly/config');
global $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
@@ -1046,6 +1099,7 @@ class moduleConfigRoute
]
);
$this->post('/shelly/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/shelly/config');
global $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
@@ -1060,6 +1114,7 @@ class moduleConfigRoute
});
/** Self-Serve config -> GET */
$this->get('/selfserve/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/selfserve/config');
global $response;
$this->requirePermission('modules_selfserve_config');
$user = (new authentication())->get_user();
@@ -1077,6 +1132,7 @@ class moduleConfigRoute
]
);
$this->post('/selfserve/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/selfserve/config');
global $response;
$this->requirePermission('modules_selfserve_config');
$user = (new authentication())->get_user();
@@ -9,6 +9,9 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEconomicCustomerRoute
{
use route_t;
@@ -22,6 +25,7 @@ class moduleEconomicCustomerRoute
/** Modules > Economic > Customer > Get customer */
$this->get('/modules/economic/customer', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/economic/customer');
global $response;
$this->requirePermission('modules_economic_customer_get');
$user = (new authentication())->get_user();
@@ -45,6 +49,7 @@ class moduleEconomicCustomerRoute
/** Modules > Economic > Customer > Create customer */
$this->post('/modules/economic/customer', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/economic/customer');
global $response;
$this->requirePermission('modules_economic_customer_create');
$user = (new authentication())->get_user();
@@ -11,6 +11,9 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEconomicRoute
{
use route_t;
@@ -24,6 +27,7 @@ class moduleEconomicRoute
/** Economic > Customers > Import customer */
$this->post('/economic/customers/import', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/customers/import');
global $response;
$this->requirePermission('economic_import_customer');
$user = (new authentication())->get_user();
@@ -8,6 +8,9 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEntraRoute
{
use route_t;
@@ -21,6 +24,7 @@ class moduleEntraRoute
/** Modules > Entra > Users > GET */
$this->get('/modules/entra/users', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/entra/users');
global $response;
$this->requirePermission('modules_entra_users');
$user = (new authentication())->get_user();
@@ -9,6 +9,8 @@ use classes\router;
use objects\currency_conversion_rates_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleFxRatesAPIRoute
{
@@ -23,6 +25,7 @@ class moduleFxRatesAPIRoute
/** Modules > FXRatesAPI > conversion rate > GET */
$this->get('/modules/fxratesapi/rate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/fxratesapi/rate');
global $response;
$this->requirePermission('modules_fxratesapi_rate');
$user = (new authentication())->get_user();
@@ -10,6 +10,9 @@ use limble\helpers\limble_tasks_pagination;
use limble\helpers\limble_webhook_payload_task;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleLimbleRoute
{
use route_t;
@@ -20,6 +23,7 @@ class moduleLimbleRoute
/** @var router $router */
$router, $response;
$this->post('/modules/limble/webhook/task', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/limble/webhook/task');
global $response;
$slack = new slack();
$this->requirePermission('modules_limble_webhooks_task');
@@ -41,6 +45,7 @@ class moduleLimbleRoute
);
$this->get('/modules/limble/tasks', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/limble/tasks');
global $response;
$slack = new slack();
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
@@ -9,6 +9,9 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleMotorAPIRoute
{
use route_t;
@@ -22,6 +25,7 @@ class moduleMotorAPIRoute
/** Modules > MotorAPI > Lookup > GET */
$this->get('/modules/motorapi/lookup', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/motorapi/lookup');
global $response;
$this->requirePermission('modules_motorapi_lookup');
$user = (new authentication())->get_user();
@@ -10,6 +10,9 @@ use objects\logs_o;
use stdClass;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleN8nRoute
{
use route_t;
@@ -21,6 +24,7 @@ class moduleN8nRoute
$router, $response;
$this->get('/modules/n8n/workflows', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows');
global $response;
$this->requirePermission('modules_n8n_workflows_view');
$user = (new authentication())->get_user();
@@ -46,6 +50,7 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/workflows/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows/{id}');
global $response;
$this->requirePermission('modules_n8n_workflows_view');
$user = (new authentication())->get_user();
@@ -62,6 +67,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -80,6 +86,7 @@ class moduleN8nRoute
]);
$this->put('/modules/n8n/workflows/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -98,6 +105,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows/{id}/publish', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/publish');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -116,6 +124,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows/{id}/deactivate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/deactivate');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -131,6 +140,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/webhooks/trigger', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/webhooks/trigger');
global $response;
$this->requirePermission('modules_n8n_workflows_run');
$user = (new authentication())->get_user();
@@ -152,6 +162,7 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/executions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions');
global $response;
$this->requirePermission('modules_n8n_executions_view');
$user = (new authentication())->get_user();
@@ -176,6 +187,7 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/executions/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions/{id}');
global $response;
$this->requirePermission('modules_n8n_executions_view');
$user = (new authentication())->get_user();
@@ -191,6 +203,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/executions/{id}/retry', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/retry');
global $response;
$this->requirePermission('modules_n8n_workflows_run');
$user = (new authentication())->get_user();
@@ -211,6 +224,7 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/executions/{id}/stop', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/stop');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -6,6 +6,9 @@ use classes\licenseplaterecognizer;
use classes\response;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleScannerRoute
{
use route_t;
@@ -37,6 +40,7 @@ class moduleScannerRoute
$router, $response;
/** Modules > Scanner > License Plate Recognition > POST */
$this->post('/modules/scanner/lpr', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/scanner/lpr');
global $response;
$route_started_at = microtime(true);
$image_upload = self::getLPRImageUpload();
@@ -33,6 +33,9 @@ use objects\subusers_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleSelfServeRoute
{
use route_t;
@@ -50,6 +53,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Status */
$this->get('/modules/self-serve/lane/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/status');
global $response;
$this->requirePermission('modules_selfserve_lane_status_view');
$selfserve = new selfserve();
@@ -72,6 +76,7 @@ class moduleSelfServeRoute
);
$this->put('/modules/self-serve/lane/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/status');
global $response;
$this->requirePermission('modules_selfserve_lane_status_set');
@@ -127,6 +132,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/in-progress');
global $response;
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
@@ -347,6 +353,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Wash > My active wash */
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/my-active-wash');
global $response;
$principal_scope = $this->requireMyActiveWashPrincipalScope();
@@ -367,6 +374,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Sessions */
$this->get('/modules/self-serve/sessions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions');
global $response;
$this->requirePermission('modules_selfserve_sessions_view');
@@ -420,6 +428,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Session detail */
$this->get('/modules/self-serve/sessions/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions/{id}');
global $response;
$this->requirePermission('modules_selfserve_sessions_view');
$session_id = (int)$this->fromRoute('id');
@@ -439,6 +448,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > Stop */
$this->post('/modules/self-serve/lane/force/stop', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/stop');
global $response;
$this->requirePermission('modules_selfserve_sessions_force_stop');
self::requireParameters(['lane_id', 'bill']);
@@ -480,6 +490,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/command');
global $response;
$selfserve = new selfserve();
// Get the request user
@@ -649,6 +660,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/services/allowed');
global $response;
$selfserve = new selfserve();
// Validate parameters
@@ -756,6 +768,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */
$this->post('/modules/self-serve/lane/gate/open', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/gate/open');
global $response;
$this->requirePermission('modules_selfserve_lane_gate_open');
$selfserve = new selfserve();
@@ -805,6 +818,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch status */
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/status');
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
@@ -840,6 +854,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/set');
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'commands']);
@@ -869,6 +884,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch poll */
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/hardware/batch/{batch_id}');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
try {
@@ -882,6 +898,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_program_picker/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
$selfserve = new selfserve();
@@ -904,6 +921,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
$selfserve = new selfserve();
@@ -935,6 +953,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
$this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_cleaner/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
$selfserve = new selfserve();
@@ -957,6 +976,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
$selfserve = new selfserve();
@@ -988,6 +1008,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE status */
$this->get('/modules/self-serve/lane/relay/machine/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
$selfserve = new selfserve();
@@ -1010,6 +1031,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
$this->post('/modules/self-serve/lane/relay/machine/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_set');
$selfserve = new selfserve();
@@ -1058,6 +1080,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
$selfserve = new selfserve();
@@ -1091,6 +1114,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
$selfserve = new selfserve();
@@ -1124,6 +1148,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/enable');
global $response;
$selfserve = new selfserve();
// Validate parameters
@@ -1173,6 +1198,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
$selfserve = new selfserve();
@@ -1206,6 +1232,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
$selfserve = new selfserve();
@@ -1233,6 +1260,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
$selfserve = new selfserve();
@@ -1266,6 +1294,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
$selfserve = new selfserve();
@@ -1293,6 +1322,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
$this->post('/modules/self-serve/lane/force/machine/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_enable');
$selfserve = new selfserve();
@@ -1367,6 +1397,7 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */
$this->post('/modules/self-serve/lane/force/machine/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_disable');
$selfserve = new selfserve();
@@ -11,6 +11,9 @@ use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleStripeRoute
{
use route_t;
@@ -24,6 +27,7 @@ class moduleStripeRoute
/** Modules > Stripe > Customers > List */
$this->get('/modules/stripe/customers', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/customers');
global $response;
$this->requirePermission('modules_stripe_customers_list');
$user = (new authentication())->get_user();
@@ -43,6 +47,7 @@ class moduleStripeRoute
/** Modules > Stripe > Products > List */
$this->get('/modules/stripe/products', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/products');
global $response;
$this->requirePermission('modules_stripe_products_list');
$user = (new authentication())->get_user();
@@ -62,6 +67,7 @@ class moduleStripeRoute
/** Modules > Stripe > Prices > List */
$this->get('/modules/stripe/prices', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/prices');
global $response;
$this->requirePermission('modules_stripe_prices_list');
$user = (new authentication())->get_user();
@@ -81,6 +87,7 @@ class moduleStripeRoute
/** Modules > Stripe > Retired direct payment-link creation */
$this->post('/modules/stripe/invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
global $response;
$this->requirePermission('modules_stripe_invoice_send');
$response->error([
@@ -95,6 +102,7 @@ class moduleStripeRoute
/** Modules > Stripe > Cancel Invoice */
$this->delete('/modules/stripe/invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
global $response;
$this->requirePermission('modules_stripe_invoice_send');
$user = (new authentication())->get_user();
@@ -6,6 +6,9 @@ use classes\module_usage_service;
use Exception;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleUsageRoute
{
use route_t;
@@ -13,6 +16,7 @@ class moduleUsageRoute
public function run(): void
{
$this->get('/modules/usage/summary', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/summary');
global $response;
$this->requirePermission('modules_usage_view');
@@ -27,6 +31,7 @@ class moduleUsageRoute
]);
$this->get('/modules/usage/{moduleKey}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/{moduleKey}');
global $response;
$this->requirePermission('modules_usage_view');
@@ -41,6 +46,7 @@ class moduleUsageRoute
]);
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/quotas/{moduleKey}/{metricKey}');
global $response;
$this->requirePermission('modules_quotas_manage');
@@ -11,6 +11,9 @@ use objects\currency_conversion_rates_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleVirkDataRoute
{
use route_t;
@@ -24,6 +27,7 @@ class moduleVirkDataRoute
/** Modules > VirkData > search > GET */
$this->get('/modules/virkdata/search', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/virkdata/search');
global $response;
$this->requirePermission('modules_virkdata_search');
$user = (new authentication())->get_user();
@@ -19,6 +19,8 @@ use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleWeatherAPIRoute
{
@@ -46,6 +48,7 @@ class moduleWeatherAPIRoute
$router, $response;
$this->get('/modules/weatherapi/current', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/weatherapi/current');
global $response;
$this->requirePermission('modules_weatherapi_current');
$user = (new authentication())->get_user();

Some files were not shown because too many files have changed in this diff Show More