Compare commits

..
Author SHA1 Message Date
bugfix-subagent c9c08dea56 test(api): wire up real mysqli in SchemaHealthCheckTest (TRU-77 unit CI)
The Unit suite starts with a freshly-dropped test database and does
not create a $db global, so:

- customer_invoice_email_schema_bootstrap::ensureSchema() short-
  circuited and never created the users table.
- runSchemaCheck had nothing to inspect and reported ok=false.

Wire up a real mysqli connection at file load time using the same
CONFIG_DB_* env vars the rest of the CI suite exports, then have
beforeEach run the bootstrap and create the bare-minimum invoices /
bookings tables that runSchemaCheck verifies. This keeps the test
self-contained inside the Unit suite without changing the
production code or the production schema.

Refs: api#383, TRU-77
2026-08-16 19:01:46 +00:00
Jeppe B 9e18f8988b feat(api): schema health check + pre-deploy migration runner
Closes the 'Unknown column invoice_email in SELECT' production failure
mode (TRU-77) by:

- New GET /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
2026-08-16 18:48:38 +00:00
Jeppe B 34df80530c feat(api): product merging infrastructure for SF (TRU-94) (#379)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-16 20:45:10 +02:00
3d0a8eeae7 feat(api): add optional invoice_email field for customers (TRU-77) (#381)
## Summary

Adds an optional `invoice_email` (Danish: *faktura email*) field to
customers, so e-conomic can deliver invoices to a dedicated accounting
mailbox instead of the customer's primary email.

Linear: **TRU-77** (DRIFT 16)

## Changes

- **Migration** (additive, via existing schema_bootstrap pattern)
- New `customer_invoice_email_schema_bootstrap` adds the `invoice_email
VARCHAR(255) NULL` column to `users` after `wash_certificate_email`.
Idempotent — skips when the column already exists.

- **Domain object — `objects/users_o.php`**
  - New `invoice_email` object property.
- `getInvoiceEmail()` returns the dedicated address or falls back to the
primary `email`.
- `getInvoiceEmailOverride()` returns only the explicit override (no
fallback).
- `setInvoiceEmail($email)` validates and writes the value; `null`/empty
clears it.
- `add($customer_number, $password, $role, ?$invoice_email = null)` now
accepts the optional field and persists it.
- The user payload output now exposes `invoice_email` and
`invoice_email_fallback`.

- **API — `routes/usersRoute.php`**
- `POST /users` accepts an optional `invoice_email`, validated before
insert.
- `PUT /users` accepts `invoice_email` (including null/empty to clear)
on existing users.

- **Customer mass import — `classes/customer_mass_import_service.php`**
  - Payload now accepts `invoice_email`.
- `normalizeInvoiceEmail()` rejects malformed addresses before any
e-conomic call.
- `resolveInvoiceEmail()` / `resolveCreateEmail()` route the e-conomic
customer email to the dedicated address when set, otherwise the primary
`email` (with the existing `jb@truckwash.dk` fallback when neither is
provided).
  - `syncLocalCustomer()` persists `invoice_email` on the local user.
  - `import()` result now includes the resolved `invoice_email`.

- **Tests — `tests/Unit/Customers/CustomerInvoiceEmailTest.php` (new)**
  - Schema bootstrap adds the column when missing.
  - Schema bootstrap is a no-op when the column already exists.
  - Schema bootstrap skips when the `users` table is not present.
  - e-conomic customer email is set to `invoice_email` when provided.
- e-conomic customer email falls back to `email` when `invoice_email` is
omitted.
  - Invalid `invoice_email` is rejected before any e-conomic call.

## Backwards compatibility

- The column is nullable; existing rows are unaffected.
- The `add()` signature is additive (new optional parameter with default
`null`).
- The route payloads ignore `invoice_email` unless supplied, so no
client change is required.

## Linear

- TRU-77 (DRIFT 16: "Add 'faktura email' field to customer creation
form")

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix Bot <openclaw-bot@truckwash.dk>
Co-authored-by: bugfix sub-agent <bugfix@openclaw.local>
2026-08-16 18:58:05 +02:00
Jeppe BOpenClaw Backend AgentJeppe Bjeppemaxclaw[bot] <bot@jeppemaxclaw.local>Bugfix Subagent
60222a7d91 fix(api): clarify user-invoice PUT validation so customers can invoice (TRU-128) (#382)
## Summary

Fixes **TRU-128** ("Jeg kan ikke fakturere") — a customer in
#afdelingsansvarlige could not invoice because the customer-facing
invoice PUT endpoint returned a misleading 400 error.

## Root cause

`PUT /collected-invoices` in
`services/nginx/app/routes/userInvoicesRoute.php` had two related bugs:

1. **Misleading error message** — the 'both fields missing' guard
errored with
   `'Missing required parameters: po_number, closed_at'`, which reads as
   if BOTH fields are required. The actual condition (`&&`) only fires
   when neither is set, so only one is required. Customers who tried
   different combinations kept getting the same error and concluded the
   system was broken.

2. **Inconsistent `closed_at` clearing** — the 'forbidden closed_at for
   non-superusers' guard fired for ANY present `closed_at` key,
   including `null` and `""`. That blocked customers from CLEARING a
   previously-set `closed_at`, even though the handler further down
   already nulls the field when it receives an empty value.

## Fix

- Reword the missing-fields error to state the actual contract:
  *"At least one of po_number or closed_at must be provided"*.
- Narrow the forbidden guard to *non-empty* `closed_at`, so customers
  can still pass `null` / `""` to clear a previously-set value.
  The clear-on-null/empty logic further down in the handler is unchanged
  — the guard now matches it.

## Test

`tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`
- Locks in the new error message.
- Locks in the new `$closed_at_is_non_empty` guard shape with the
  `if (self::isParametersSet(['closed_at'])) { ... }` pre-check.
- Locks in the regression: the previous 'any present closed_at -> 403'
  pattern is explicitly asserted to be absent.

## Files changed

- `services/nginx/app/routes/userInvoicesRoute.php`
-
`services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`

## Refs

- TRU-128
- Slack: #afdelingsansvarlige (kunde-rapport)

---------

Co-authored-by: OpenClaw Backend Agent <agent@openclaw.ai>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: jeppemaxclaw[bot] <bot@jeppemaxclaw.local>
Co-authored-by: Bugfix Subagent <bugfix-subagent@openclaw.local>
2026-08-16 18:20:03 +02:00
78b11d0b79 fix(api): route invoices to correct Economic account per-customer (TRU-18) (#380)
## Summary

Fixes **TRU-18 / AUT-14** — `truckwash.io` invoices were being routed to
the wrong Economic (EC) account for some users.

## Root cause

`getUserByCustomerNumber()` in `services/nginx/app/objects/users_o.php`
trusted the **inverse Redis cache** (`customer_number → user_id`)
without verifying that the user it loaded actually owned the requested
EC customer_number in the local DB.

When that cache went stale — e.g. after a `customer_number` re-mapping
on a code path that did not clear the inverse-cache entry —
`getUserByCustomerNumber()` would silently return a **different user**
whose current `customer_number` no longer matched the one the caller
asked for. Downstream invoice export code (`getCustomerEcocomicData()` →
`$customer_economic->customer_number` →
`economic_invoice_draft->setCustomerNumber(...)`) then used that wrong
user's current EC customer_number, and the draft invoice was created
against the **wrong Economic account**.

Because this only manifests when the inverse cache is stale, it surfaces
as "some users" — exactly the symptom reported.

## Fix

Minimal change in `getUserByCustomerNumber()`:

1. After the Redis fast-path loads a user, read the actual
`customer_number` from the DB via `getObjectProperties()`.
2. **Verify** that it equals the requested `$customer_number`.
3. If not, the inverse cache is stale: clear it
(`clear_user_id_from_customer_number`) and re-fetch via the recursive
call, which now falls through to the authoritative `SELECT id FROM users
WHERE customer_number = ?` DB query.

The DB path was always correct (it filters by exact `customer_number`);
the bug was exclusively in the unchecked Redis fast-path.

## Regression test

`tests/Unit/Users/GetUserByCustomerNumberStaleCacheTest.php` — wiring
tests that assert the verification + cache-clear + recursive re-fetch
are present, plus that the DB lookup path is the source of truth.
Prevents the regression from reappearing silently.

## Test run

PHP is unavailable in the sandbox, so the new test has not been executed
locally. It is a pure wiring test (string assertions on the source file)
and will be verified by CI on PR open.

## Out of scope

- No change to `openclaw.json`, deployment config, or any other config
files.
- Auto-merge is intentionally **not** enabled — leaving that to the
existing auto-merge cron.
- Existing tests untouched.

## Linear

- TRU-18 will be moved to "In Review" with the PR URL in a follow-up
comment.

🤖 Generated with [MaxClaw](https://maxclaw.ai)

---------

Co-authored-by: TRU-18 backend bot <bot@truckwash.dev>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
2026-08-16 16:04:03 +02:00
Jeppe B 55ddabb0ee test(api): lock program-registry contract for TRU-19 (#377)
The api does NOT expose a /programs endpoint by design — program names ("FF Uvs", "10min", "SF", etc.) live on the wash bay hardware itself, not in the api.

This test locks that architecture so any future /programs endpoint must be explicitly added and documented, and so the machine-types endpoint remains reachable as the api-side closest equivalent.

Three assertions:
1. No /programs endpoint exists in any route file (or per-module route file)
2. /department/selfserve/machine-types is wired with the list permission and returns the success() envelope
3. /modules/self-serve/lane/relay/machine_program_picker/{status,set,enable} endpoints exist

Refs TRU-19
2026-08-16 13:00:21 +02:00
16048e2ce3 chore(release): merge develop into master — XL Vask flag text fix + edge-broker health (Aug 15 2026) (#376)
Brings all of the develop branch's commits into master.

## What this contains

The 2 commits on develop that landed during the XL Vask integration
dispatch:

- **PR #373** (TRU-6 / AUT-2) — feat(edge-broker): expose lastActivityAt
on /api/health (AUT-2/TRU-6)
- **PR #375** (TRU-49 / AUT-49) — fix(api): include wash_id in
xlvask_missing_order_link flag text (AUT-49/TRU-49)

## Why

The XL Vask integration dispatch via the OpenSymphony orchestrator
(MiniMax M3) produced 2 api-side fixes:
- **PR #373** — adds `lastActivityAt` to the api health endpoint so
operators can see if the edge-broker has processed any requests
recently.
- **PR #375** — the actual root-cause fix for the user-reported symptom
"XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre
i den valgte periode doesn't show the wash". The bug was in
`messageParts()` for the `xlvask_missing_order_link` arm — the link text
was hard-coded to 'XL Vask wash' instead of using the actual wash_id.
This PR makes the link identify the wash it points to.

## Verification

Both source PRs passed:
- Required CI (PHP unit, PHP integration, PHP api, PHP legacy, edge
broker, edge agent, edge gateway backend)
- The api ruleset allows squash merges

## Notes

- The pleno-vue repo has its own equivalent develop→master PR (#312)
with the 9 UI fixes (component, i18n, and a Playwright E2E).

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 09:08:03 +02:00
Jeppe Bandopenhands cdf8541e78 fix(api): exclude spot-free-lastbil from audited add-on reason policy (#372)
## Problem

The mobile POS step 2 \"enter note\" dialog was triggering for product
24
(\"Højtryk - ekstra tid\" / spotfree-lastbil) because product 24 was
listed
in `AFFECTED_PRODUCT_IDS` in
`services/nginx/app/classes/order_item_reason_policy.php`.

Product 24 is the \"spot-free-lastbil\" package, not an audited
extra-time
add-on — the server-side reason policy should only enforce the comment
requirement on {21, 22, 25, 26, 27}, matching the frontend
`AUDITED_ORDER_ITEM_PRODUCT_IDS` set.

Companion to the frontend PR copenhagentruckwash/pleno-vue#301.

## Fix

Drop product 24 from `AFFECTED_PRODUCT_IDS`.

## Verification

Tracked under workboard-94209138-31f6-422e-ac8c-181ad391b8a7.

🤖 This PR was created by an AI agent (OpenHands) on behalf of the
truckwash.io team.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 21:11:44 +02:00
Jeppe Bandopenhands 51e5c2ed01 feat(invoicing-period): surface lightweight customer membership on non-active views (#371)
## Summary

Customer indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*,
*Tankrengøring*) currently only render on the matching view tab because
the period paged response strips customer data from every non-active
view bucket. The front-end therefore cannot determine which other
categories a customer belongs to from the *Alle* tab.

This change projects a deduplicated lightweight customer marker
`{customer_number, membership_only: true}` onto every non-active view
bucket in `applyPeriodPagination`. The active bucket still carries full
customer cards so paginated full-data output, type_counts and
type_totals are unchanged. Filters, search, sort, flag tab and workflow
filters upstream of the membership projection make the non-active
membership set match the active-bucket semantics for the same request.

## Contract change (openapi.yaml)

* New schema `InvoicingPeriodCustomerMembership` with `{
customer_number, membership_only: true }` and `additionalProperties:
false`.
* `InvoicingPeriodData.types[view].items` is now a `oneOf` of
`InvoicingPeriodCustomer` and `InvoicingPeriodCustomerMembership`.
* `InvoicingPeriodCustomer.required` relaxed to `customer_number` only
(other fields are now reported per-view).

## Implementation

* New helper `summarizeNonActiveCustomerMemberships()` projects +
deduplicates by `customer_number`.
* Pagination emits full cards for the active bucket and lightweight
memberships everywhere else.

## Tests

* Updated existing `fixed_pricing` assertion to include the membership
marker.
* Added four new tests:
* default projection across all view buckets (with explicit dedup
assertion),
  * search filter propagation,
  * flag-tab filter propagation,
  * single-customer active-bucket edge case.

All 15 `InvoicingPeriodPaginationTest` tests pass locally (158
assertions).

🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 15:16:25 +02:00
Jeppe B 0dea2f0b76 fix(ci): run api edge-agent and release-manager-gate on GitHub-hosted runner (#370)
The self-hosted backend runner pool has been offline since Aug 9. Switching the two jobs that hard-required it to ubuntu-24.04 (GitHub-hosted) restores the normal release pipeline for the api. After this lands, the next master push will flow Tests -> Required CI -> Release Manager gate -> channel_sync -> api-v2 deploy.

🤖 This PR was merged by an AI agent (OpenHands) on behalf of jepp9350.
2026-08-13 13:29:14 +02:00
Jeppe B 22ec6696b5 chore(agent-mcp-smoke): verify GitHub MCP write/PR wiring (#369)
Generated automatically by Hermes to verify the GitHub MCP is wired into
OpenHands.

Safe to close — no production change.
2026-08-13 11:30:03 +02:00
Jeppe Bandopenhands a71194d46e refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## What

Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).

The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:

```php
namespace traits;
trait boolean_normalization_t {
    public static function normalizeBoolean(mixed $value): bool {
        if (is_bool($value)) return $value;
        return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
    }
}
```

`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:

| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |

## Why

The pre-PR repo had two silent bugs:

1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.

## Tests

* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.

## Risk

* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.

## Co-author

Co-authored-by: openhands <openhands@all-hands.dev>

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:20:03 +02:00
Jeppe Bandopenhands 5441fea665 fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)
## Summary

Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline
and the related module config, CLI, cron, and migration scaffolding. The
Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced
to a single read-only listing of usage logs plus operator-driven ignore
/ unignore / accept / reject endpoints gated on the
`review_xlvask_usage_order` permission.

See `inventory/self-serve-inventory.md` for the full surface map.

## Test plan

- [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated
pre-existing failure (`BirdControlPlaneActivationTest`, needs
`PLENO_REPO_ROOT_FOR_TESTS`).
- [x] `php -l` on every modified PHP file -> no syntax errors.
- [x] Grep validation -> zero production-code references to removed
surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`,
`xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`,
`runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`,
`minimax`, ...).
- [ ] Qodana + Tests workflows green on this PR.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:02:30 +02:00
Jeppe BandHermes Agent 02a4665bc7 test(api): lock XL Vask Selvvask review capabilities end-to-end (#366)
## Why

The Selvvask view depends on the `/automation/capabilities` endpoint
returning `can_review=true` and the `/decisions/preview` endpoint
admitting the operator. Both were previously locked to
`manage_xlvask_usage_automation` and silently disabled the Accept /
Reject / Ignore buttons for every operator. copenhagentruckwash/api#365
fixed the contract; this PR adds the missing end-to-end API tests so a
future refactor cannot re-tighten the gating without anyone noticing.

## What changed

`tests/Api/XLVaskReviewApiTest.php` — five `usesApiSuite` specs that
exercise the new contract against a real MySQL + Redis stack with a
`createUserSession` fixture:

- `list_xlvask_usage_orders_own` lights `can_review=true` (the
back-compat path so existing operator groups work without a permission
grant).
- `review_xlvask_usage_order` lights `can_review=true`.
- The new permission does NOT unlock `can_dry_run` / `can_execute` /
`can_manage_policy` / `can_halt`, so an operator cannot trigger the
autopilot or change policy from the selvvask view.
- An operator with no xlvask permissions is rejected at the capability
inspection gate with a 403 + missing-permission envelope.
- An operator with only `list_xlvask_usage_orders_own` can inspect
capabilities but is rejected at `/decisions/preview` (which still
requires `review_xlvask_usage_order` or
`manage_xlvask_usage_automation`).

## Notes

The test was committed to master directly because that's where the
contract lives; this PR is the back-port to a branch so the CI Required
gate can run on it.

---------

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-12 12:12:20 +02:00
Jeppe BandHermes Agent 3e683c8559 fix(api): allow operators to review XL Vask automation decisions in the Selvvask view (#365)
## Why

Operators on the Superuser → Fakturaer → Periode → Selvvask view were
unable to Accept / Reject / Ignore / Link XL Vask washes even though the
UI claimed the buttons should be there. The previous capability contract
only lit `can_review` for users with `manage_xlvask_usage_automation`, a
small admin group, so the FE never rendered any review actions for the
rest of the superuser staff. The same contract also blocked the
corresponding `/decisions/preview` and `/decisions/apply` calls, so even
if the buttons were forced on, the API would 403.

## What changed

- `xlvaskUsageLogsRoute.php`:
  - New permission string `review_xlvask_usage_order` for operators.
  - `/modules/xlvask/services/usage/automation/capabilities`:
- `can_review` now returns `true` when the user has
`review_xlvask_usage_order` or the existing `list_xlvask_usage_orders_*`
(so existing operator groups keep working without an extra grant), or
`manage_xlvask_usage_automation` (the AI admin path).
- `can_dry_run` / `can_execute` / `can_manage_policy` / `can_halt`
remain gated on the AI-admin permissions to keep the autopilot lifecycle
fail-closed.
- The new permission is registered in the route's permission manifest.
- `/modules/xlvask/services/usage/automation/decisions/preview` and
`/apply` now accept either `manage_xlvask_usage_automation` or
`review_xlvask_usage_order`. The existing `force_manual` branch in
`xlvask_autopilot_service` lights up automatically for these operators,
so the existing manual-suggestion path drives them.
- The AI autopilot run lifecycle (`/autopilot-runs`,
`/autopilot-runs/{id}`, `/autopilot-runs/active`,
`/automation/admin/...`) still requires `manage_xlvask_usage_automation`
/ `superuser_xlvask_automation_activate`.

## Tests

- New contract in `XLVaskUsageRouteContractTest`:
- "exposes a review_xlvask_usage_order permission on decision endpoints
for the selvvask operator flow" — locks the new permission string, the
new `can_review` flag, and the manage-only `can_dry_run` / `can_execute`
flags.
- "still requires manage_xlvask_usage_automation for the AI autopilot
run lifecycle" — regression guard for the admin path.
- All 87 XLVask unit tests pass. The wider 1334 unit tests also pass;
the only pre-existing failure is the unrelated
`BirdControlPlaneActivationTest` which requires
`PLENO_REPO_ROOT_FOR_TESTS` and is broken on master.

## Companion frontend PR

`copenhagentruckwash/pleno-vue` → `fix/xlvask-selvvask-review-actions`
(the FE was already wired correctly: `allow-review-actions =
automationWorkspace && capabilities.can_review`. With the API change
above, `can_review` now lights up for operators so the buttons surface.
A new source-inspection regression test pins the contract so future
edits cannot re-tighten the gating.)

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-11 23:58:21 +02:00
Jeppe B d81634033e fix(api): do not raise historical_primary_product_mismatch for single-tractor orders (#363)
Partition primary rows by reg_2 presence in getPrimaryProductHistory() so a single-tractor order (reg_2 empty) is compared only against other single-tractor orders, not against historical tractor-trailer orders. 2 new tests + 1 updated signature contract test in InvoicePeriodFlagServiceTest.
2026-08-11 07:39:00 +02:00
Jeppe B 58d5e177a9 fix(api): order order_items so primary precedes addons in getOrderItems (#364)
Defensive ORDER BY in orders_o.php::getOrderItems so primary items render before their addons (related_item_id IS NULL DESC, related_item_id ASC, id ASC). Pinned with OrderItemsListingOrderingTest which locates orders_o.php via worktree-aware resolver.
2026-08-11 07:05:59 +02:00
Jeppe BandWorktree Fix Verifier 82a3684f05 fix(api): order getOrdersWithRegistrationNumberInDateRange by id ASC (#362)
## Summary

orders_o::getOrdersWithRegistrationNumberInDateRange() selects orders
matching a registration number within a date range without an explicit
ORDER BY clause. MySQL is free to return rows in any order. The endpoint
at routes/orderInvoicesRoute.php then iterates the result and calls
assignToInvoiceCollection() on each row, so the audit-log +
invoice-collection numbering depend on the arbitrary backend row order.

Add a stable `ORDER BY id ASC` to the SELECT and pin the contract with a
new Pest unit test.

## Test plan

- New Pest test `OrdersRegistrationDateRangeQueryTest` asserts the
SELECT still carries `ORDER BY id ASC`.
- Existing tests in the same file still pass unchanged (they don't
assert on ordering).
- Manual php -l on both modified files shows no syntax errors.

## Commits

- bbd50239 fix(api): order getOrdersWithRegistrationNumberInDateRange by
id ASC

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-10 20:33:10 +02:00
Jeppe BandTruck Wash Agent d850075397 fix(api): order order_items so primary precedes addons in getOrderItems (#361)
## Summary

orders_o::getOrderItems() selected order items without an explicit ORDER
BY clause, so MySQL was free to return rows in any order. On the POS
Fuldfør click and the superuser invoice tree, addons (related_item_id !=
NULL) were sometimes returned before their primary item, which broke the
FE tree-builder and the OrderContentTable render.

Add a stable ordering: primary items first (related_item_id IS NULL
DESC), addons grouped by their parent (related_item_id ASC), and
insertion order as the final tiebreaker (id ASC).

## Commits

- 7ec64ec8 fix(api): order order_items so primary precedes addons in
getOrderItems
- 68e19bee test(api): pin order_items listing ordering in getOrderItems

## Test plan

- Wiring unit test asserts the SELECT inside getOrderItems still carries
ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC.
- Verified locally with php -l on the modified file.
- Existing OrderItemReasonPolicyTest, CustomerOrderProductPolicyTest,
OrdersIncludeInInvoiceOverrideTest continue to pass in the worktree
setup (no DB fixtures touched).

---------

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-10 20:16:04 +02:00
Jeppe BandTruck Wash Agent 43df3e4dca fix(api): only require notes when the product actually requires them on POST /order/items (#360)
## Bug
PR #345 (order_item_reason_policy wiring) accidentally broadened the
legacy
\`Notes is required for this product\` check to fire for every product
whose
POST body carried an empty/whitespace \`notes\` field.

Mobile POS step 2 always posts the primary product (e.g. Sættevognstræk,
product id 3) with \`notes: ''\` as part of
\`syncCurrentTransactionToOrder\`. After #345, the API started returning
400 for that primary item. The frontend silently swallowed the 400 in
the next-step click handler, and the operator saw **"Fuldfør doesn't
continue"** with no feedback.

## Repro
1. Log in to the mobile POS (e.g. dept 12 / Taulov)
2. Scan / type a customer's plates (e.g. EP68666 + GG1876)
3. Long-press Sættevognstræk to add the service
4. Tap **Fuldfør**

Before this fix: \`POST /order/items\` → 400 \`Notes is required for
this product\`. Frontend catches and logs \`Next-step action was
interrupted: AxiosError: Request failed with status code 400\`. Operator
sees no error in the UI.

After this fix: \`POST /order/items\` → 200 for the primary product; the
order completes normally.

## Fix
Scope the empty-notes rejection to products whose \`requires_note\` flag
(or extraordinary-chemistry special case) is set, matching the existing
PUT handler behaviour. Products that don't require notes can post
\`notes=''\` without rejection.

## Lock-in tests
Two Pest tests under \`Tests\\Api\\OrderItemsApiTest\`:
- \`allows empty notes for primary products that do not require a note\`
— \`requires_note=0\` product with \`notes=''\` returns 200
- \`still rejects empty notes for products whose requires_note flag is
enabled\` — \`requires_note=1\` product with \`notes=' '\` returns 400
with the legacy message

## Verification
PHP API suite: **292/292 passing** (11892 assertions). Local
\`scripts/php-ci-test.sh api\`.

## Companion PR
\`copenhagentruckwash/pleno-vue\` →
\`fix/fuldfor-surface-order-item-error\` will surface order-item API
errors in the UI so silent failures become visible. That PR is a
follow-up; this one is the actual root cause fix.

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-10 11:32:11 +02:00
Jeppe B 7174e3be6c fix(api): coerce string booleans before persisting module_config bool values (#359)
PHP truthy semantics treat the literal `'false'` as truthy, so `$value ? 'true' : 'false'` in `setVariableValue()` persisted every 'switch off' request as `'true'` for ~30 modules with bool config variables. Canonicalise via the existing `inputToBool()` helper before the ternary so JSON.stringify(boolean) inputs land on the right storage string.

Live repro (api-v2.truckwash.io, 2026-08-10 09:25):
```
POST /minimax/config {variable:"enabled", value:"false"} → 200
GET  /minimax/config?variable=enabled → {value:true, ...}      ← unchanged
POST /minimax/config {variable:"enabled", value:false}   → 200
GET  /minimax/config?variable=enabled → {value:false, ...}     ← JSON bool works
```

Tests: `tests/Unit/MiniMax/MiniMaxEnabledSetVariableValueTest.php` (6 assertions, captures the UPDATE column value via stubbed `updateVariableValue`/`insertVariableValue` for string/boolean × true/false × insert/update paths).

Companion pleno-vue PR #281 lands the matching UI-side fix (`onMiniMaxEnabledSwitch` re-fetches + optimistic rollback).
2026-08-10 09:21:56 +02:00
Jeppe B 2ef0f78541 test(api): lock MiniMax config redaction + isSet contract (#358)
Regression coverage for the read-side shape that the frontend `ConfigurationXLVask.refreshMiniMaxApiKeyStatus` parses. Mirrors `BirdConfigSecretRedactionTest` — locks the contract that `api_key` is redacted with `isSet` reflecting actual persistence, so the XL Vask autopilot's `requireModuleEnabled` cannot silently break on a regression.

No production-code change — backend already correct (verified live 2026-08-10 07:58 against `api-v2.truckwash.io` with both `{variable, value}` and raw-key payload shapes).

Companion pleno-vue PR: #281 ("fix(pleno-vue): persist MiniMax API key across refresh + render fix").
2026-08-10 08:45:10 +02:00
Jeppe B 801c8e1f6d feat(api): standalone xlvask-automation-migrate script + runbook section (#357)
Adds `scripts/xlvask-automation-migrate.php` mirroring the existing schema-script pattern (`check` / `apply --yes`), a new AUTOMATION_RUNBOOK §2a documenting both operator entry points, and `XLVaskAutomationMigrateScriptTest` pinning the gate, the WD check, and the bootstrap references.

Production autopilot-runs (POST /modules/xlvask/services/usage/autopilot-runs) was returning 500 with:
```
XL Vask automation schema is not ready. Apply migration 20260804_xlvask_ai_auto_policy_v2 explicitly.
```
because no operator had invoked the gated `applyExplicitMigration()` since PR #348 shipped the migration class. The frontend half of the fix is the companion change in pleno-vue#280 (missing `minimax_integration_enabled` key).

Operator action required once merged:
```
php index.php run xlvask-automation-migrate
# or
php scripts/xlvask-automation-migrate.php apply --yes
```
Both produce identical JSON status; retain the artifact and rerun `check` to confirm postflight is green.

Diff: +140/-0 (3 files). Tests: 87/87 XL Vask unit + 5/5 new migration script test pass.
2026-08-10 08:18:28 +02:00
Jeppe BandCleanup Agent 3e89085296 feat(api): support manual XL Vask operator decisions via force_manual (#356)
Adds a deterministic manual-suggestion path so operators can drive
accept/reject/ignore decisions on the self-wash view before the AI
autopilot has produced a suggestion. Whitelists force_manual in the
preview route. Adds unit tests for the new constant, method, and route
contract.

---------

Co-authored-by: Cleanup Agent <agent@truckwash.io>
2026-08-09 21:48:03 +02:00
Jeppe BandTruck Wash Agent 2cf2538525 feat(api): add MiniMax M3 client and force XL Vask autopilot to use it (#355)
Adds a new `modules/miniMax` module mirroring the existing OpenAI
pattern (`api_key` + `enabled` config keys). Adds a
`classes/minimax.php` client that calls
`https://api.minimax.io/anthropic/v1/messages` (the same endpoint
OpenClaw's minimax-portal provider uses) and returns structured JSON via
the Anthropic tool_use response shape.

**Replaces the autopilot planner:**
- `PLANNER_MODEL`: `gpt-5.6-sol` → `MiniMax-M3`
- `xlvask_automation_service`: `new openai()` → `new minimax()` in the
planner + the isOpenAiIntegrationEnabled() guard
- New xlvask config flag `minimax_integration_enabled` gates the
autopilot. The OpenAI flag is kept so deployments can roll back.

**Compatibility shims** (so the autopilot keeps working with minimal
churn):
- `minimax_request_exception` extends `openai_request_exception`, so
every existing `catch (openai_request_exception)` block catches MiniMax
errors unchanged.
- The result payload also carries the legacy `_openai_response_model`
and `_openai_usage` aliases, so `sanitizeOpenAiResult` keeps working
unchanged.

**New endpoints:** GET/POST `/minimax/config` in `moduleConfigRoute`
guarded by `modules_minimax_config`, mirroring `/openai/config`.

**Tests:** PHP api suite 290/290 passing locally (no regressions). All
xlvask tests still pass.

**Frontend counterpart** ships in a separate PR on pleno-vue
(ConfigurationXLVask.vue + SessionUser.modules.minimax + i18n in all 5
locales).

**Followup (post-merge):** operator (jeppe) enters the MiniMax API key
in superuser XL Vask settings → I'll optimize/test/debug live XL Vask
usage logs against the new model.

---------

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-09 19:34:03 +02:00
Jeppe B 6475f817c7 fix(api): wire order_item_reason_policy into POST/PUT and persist reason fields (#345)
Adds `reason_code`, `reason_label_snapshot`, `reason_comment` columns to `order_items` and integrates the `order_item_reason_policy` class into the POST and PUT /order/items routes.

Validation order on audited products (consistent across POST and PUT):
1. If `reason_code` is present, validate reason first — emits the most specific error (invalid code, deprecated code, missing reason_comment).
2. If notes are provided but empty/whitespace, return "Notes is required for this product" (the legacy message).
3. Otherwise run reason validation — covers the missing-reason_code case.

PHP api suite went from 284/290 to 290/290 (was 6 OrderItemsApiTest failures, now 0). Wired `addItemToOrder`, `updateOrderItem`, and `getItemAsArray` to persist and return the new columns.
2026-08-09 17:53:00 +02:00
Jeppe B b107ba649c Wire XL Vask usage-log sync, order linking, and order creation (#353)
Wires the three broken paths in the cron-driven XL Vask integration:

- runSyncUsage() now calls the revision-aware importUsageLogsWithSummary() on xlvask_usage_logs_o (was a no-op stub; the upstream API was never queried for washes).
- linkImportedUsageLogsToOrders() persists xlvask_potential_order_matches rows so the accept/compare/link/deny UI has data to render.
- When automatic_order_creation_enabled is on and the wash qualifies, falls through to createOrderFromWash() → orders_o::addXLVaskOrder().
- Removes the obsolete TODO in RunXLVaskModuleCron.php.
- Returns linked + orders_created alongside the existing counters so ops can observe the pipeline.
2026-08-09 13:38:27 +02:00
Jeppe B 0aaf32efa4 Surface silent-skip paths and additional silent failures in email/booking/order flows (#352)
## Why

Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs:

- `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns
- `email::sendWashCertificateEmailToCustomer()` — 1 silent return

The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips.

## What changed

### Original commit (`0ead5de5`)
- `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`).
- `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too.
- `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change.
- **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking.

### Follow-up commit (`46a59e4e`) — silent-failure sweep

**PART A — silent returns / silent errors (10 fixes):**
- `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context.
- `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop).
- `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix).
- `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log.
- `users_o::getCustomerEcocomicData()` — same.
- `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls.

**PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability.

**PART C — real bugs (2 fixed):**
1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first.
2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A).

## How to verify

1. Deploy to staging.
2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`.
3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward.

## Follow-ups (out of scope)

- Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers.
- Move `error_log` to a proper PSR-3 logger.

## Risk

- Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying.

🤖 Generated with [OpenClaw](https://openclaw.ai)
2026-08-09 00:21:04 +02:00
8735bae8d5 Fix queue export 400 by aligning e-conomic reference payloads (#351)
## Context
Queue job **#3572** failed in `COLLECTED_INVOICE_EXPORT` with e-conomic
HTTP 400 (`Validation failed. 2 errors found.`) while creating draft for
customer `35131752`.

## Fix
- align draft payload optional references to e-conomic object references
(not id-only fragments):
  - `recipient.attention`
  - `references.customerContact`
  - `references.salesPerson`
  - `references.vendorReference` (legacy path)
  - `deliveryLocation`
- include upstream `self` links when available from customer payload
- keep both collected and legacy draft creation paths consistent
- improve e-conomic error formatting so nested annotated validation
errors and `developerHint` are included in thrown messages

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php
tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php
tests/Unit/Invoicing/EconomicUpstreamErrorFormattingTest.php
tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php
tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php
--colors=never`

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 14:04:59 +00:00
bd2ff1be9a Fix false 409 snapshot expiry in invoice-period tree preview (#350)
## Problem
`POST /superuser/invoicing/period/tree-actions/preview` could return
`409 Invoice-period snapshot is missing or expired` during normal UI
flows when a snapshot binding aged out before the user triggered the
action.

## Fix
- introduce a dedicated snapshot cache TTL
(`SNAPSHOT_BINDING_TTL_SECONDS`)
- keep preview cache TTL unchanged (`PREVIEW_TTL_SECONDS`)
- use the longer snapshot TTL for actor/customer snapshot binding writes

This preserves existing safety because snapshot bindings are still
revalidated against fresh revision data before use.

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php
--colors=never`
- `vendor/bin/pest tests/Api/CollectedInvoiceBulkActionsApiTest.php
--colors=never` (suite present; skipped without `RUN_API_TESTS=1`)

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 12:55:06 +00:00
59107a6bb2 Enforce e-conomic discount template + EAN draft metadata (#349)
## What changed
- enforce EAN draft delivery wiring by setting
`recipient.nemHandelType=ean` when customer EAN is present
- copy existing e-conomic customer metadata into draft payload:
`recipient.attention`, `references.customerContact`,
`references.salesPerson`, and `deliveryLocation`
- keep `references.other` external-id mapping intact
- remove legacy explicit `Rabat:` text-line injection and use line-level
`discountPercentage` instead
- add/update unit tests for helper extraction and discount/EAN wiring

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php
tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php
tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php
--colors=never`

## Notes
- full unit suite in this environment still has an unrelated
pre-existing failure in
`Tests\\Unit\\Bird\\BirdControlPlaneActivationTest` requiring
`PLENO_REPO_ROOT_FOR_TESTS`.
- live manual verification against customer `12345679` remains
environment-blocked due missing e-conomic credentials.

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 14:44:40 +02:00
Jeppe BandJeppe Bundgaard 622fe59f5c origin/schema-migration-xlvask-autopilot (#348)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-08-04 16:08:31 +00:00
Jeppe BandJeppe Bundgaard 23fc410d25 xlvask-autopilot (#347)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-08-04 17:46:15 +02:00
Jeppe BandJeppe Bundgaard db9f589bf7 Resolve issue causing crash when plate_scanners.deleted_at was missing. (#346)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-08-04 16:43:46 +02:00
Jeppe B ab6c3ba5b6 Fix route permission instance calls (#344)
## Root cause

`route_t::hasPermission()` and `requirePermission()` are instance
methods. Route code was invoking them with `self::`; the new XL Vask
hall-scope helper made that call from a genuinely static context,
causing PHP to throw:

`Non-static method routes\\xlvaskUsageLogsRoute::hasPermission() cannot
be called statically`

## Changes

- Invoke route permission methods through `$this` across all 273
executable legacy calls in 45 route classes.
- Make `xlvaskUsageLogsRoute::allowedHallIdsForUser()` an instance
helper and update all 13 callers.
- Preserve the existing all-scope and own-scope hall selection rules.
- Add a token-aware regression test that rejects executable
`self::hasPermission()` and `self::requirePermission()` calls, while
ignoring comments.
- Add focused XL Vask tests for global scanner hall scope and
group-limited own scope.
- Update affected route contract assertions to the instance-call form.

## Verification

- PHP lint: all 53 changed PHP files
- Focused PHPStan: changed XL Vask route and both new regression tests —
clean
- Focused regression slice: 58 passed, 748 assertions
- Full local unit suite: 1,300 passed, 9,442 assertions (1 unrelated
existing warning, 1 environment skip)
- Full local API suite: 285 passed, 11,704 assertions
- Exact-SHA GitHub Tests workflow: all 7 jobs passed (unit, API,
integration, legacy, edge gateway, and supporting checks)
- Independent exact-SHA QA gate: PASS, no findings
- Independent exact-SHA security gate: PASS, no findings
- Independent exact-SHA reviewer gate: PASS, no findings
- Remote comparison: exactly one commit ahead of
`40b104abed7723a7d1b7028190ecda0e7aeef829`; all 53 remote blob hashes
matched the reviewed worktree

## Delivery state

Draft only for human review. No merge or deployment is included. Qodana
is skipped while the PR remains draft and is therefore not represented
as a passed gate.
2026-08-04 16:04:41 +02:00
Jeppe B 40b104abed Add governed XL Vask AI invoice automation (#343) 2026-08-04 11:32:49 +02:00
Jeppe B 842b06c93f Fix frontend release version recording (#342)
Add a scoped release-gate endpoint for exact frontend SHA recording and independent readback.
2026-08-03 16:11:09 +02:00
Jeppe B 1f03b46564 Fix XL-Vask usage metadata hydration (#341)
Keep automation metadata out of the strict legacy XL-Vask helper payload while returning it separately. This restores the invoice-period XL-Vask orders GET after the guarded autopilot deployment.
2026-08-03 16:00:52 +02:00
Jeppe B 6d888a455d Automate XL-Vask invoice-period resolution (#340)
Deploy the revision-aware XL-Vask import and guarded autopilot infrastructure. Automatic actions remain fail-closed pending production readiness, calibration, dry-run, and canary gates.
2026-08-03 15:33:55 +02:00
Jeppe B 0b304a2203 Gate invoice tree activation on audit schema (#339)
## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure

## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO

## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
2026-08-03 13:03:55 +02:00
Jeppe B 068f9e254f Complete selected-customer invoice period tree (#338)
Add the authoritative revision-bound invoice collection tree and guarded cleanup, merge, price-reset, and transfer operations.
2026-08-03 12:02:34 +02:00
Jeppe B f37feef1e6 Fix customer login email session refresh (#337)
Invalidate cached auth sessions for every active customer token and return the persisted canonical login email.
2026-08-03 09:40:00 +02:00
Jeppe B c795df4aad Add invoice period review workflow (#336)
Improve the superuser invoice-period review API, stale-preview protection, queue visibility, review blockers, and e-conomic eligibility.
2026-08-02 19:20:50 +02:00
Jeppe B 1e0e051775 Harden Sæby demo registration and department scope (#335)
Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
2026-08-02 11:50:56 +02:00
Jeppe B 4587bdfb06 Restore API startup by isolating Bird activation (#334)
Keep Bird activation outside the PHP-FPM/nginx startup path so Bird configuration failures cannot make the core API unavailable. Retain guarded explicit activation and regression coverage.
2026-07-29 22:41:19 +02:00
Jeppe B a442e70744 Add secure Bird gateway for Pleno Control Plane (#332)
Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
2026-07-29 19:59:20 +02:00
Jeppe BandJeppe Bundgaard 1b161f1b96 Guard Superuser invoicing registration search fields (#331)
## Summary
- Adds a backend contract guard that Superuser invoicing period search
continues to include all separate registration columns: `reg_1`,
`reg_2`, and `reg_3`.
- This preserves the API/search behavior that the frontend object-tree
registration layout relies on.

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php --colors=always`

Note: local Composer install was run with
`--ignore-platform-req=ext-mysqli --ignore-platform-req=ext-gd` because
those PHP extensions are not enabled in this worker image; the executed
guard test does not use them.

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-29 13:54:58 +02:00
Jeppe B 448e0e50c2 Fix api-v2 CORS at the edge without changing rollout flows (#330)
Add API-only Traefik CORS middleware labels while preserving configured origins and the existing rollout/load-balancer behavior.
2026-07-29 09:01:08 +02:00
Jeppe B 710baad28e Add one-time limited backoffice login grants (#329)
## Summary

Adds the missing backend contract used by Pleno Control Plane
Conversations/Suggestions to create an employee login action safely.

- issues 60–900 second one-time limited-backoffice login grants
- persists only SHA-256 bearer digests; bearer recovery is deterministic
under the server encryption key for identical idempotent retries
- enforces manager permissions, department scope, active
managed-employee constraints, one-time atomic exchange, revocation,
expiry, and account-deletion cleanup
- adds employee-create idempotency so an approved automation retry
cannot duplicate an employee
- documents the create, revoke, and unauthenticated exchange endpoints
in OpenAPI

## Security and concurrency

- bearer values are returned only in a URL fragment and are never
written to logs or database plaintext
- employee and grant rows use a consistent employee-then-grant lock
order
- deactivation revokes outstanding grants and existing sessions in the
same transaction
- consumed, revoked, expired, or payload-mismatched idempotent replays
fail closed

## Verification

- `scripts/php-ci-test.sh api`: 273 passed, 11,086 assertions (one
inherited warning)
- focused security contract: 1 passed, 21 assertions
- PHP syntax checks passed for the service and routes
- `git diff --check` passed

## Dependency

Required by copenhagentruckwash/pleno-control-plane#1. Merge before the
matching frontend and Control Plane PRs.
2026-07-29 00:01:13 +02:00
Jeppe BandJeppe Bundgaard 42ddce84bc Serialize VAT collection mutations with payment operations (#326)
## Summary

- Makes Stripe Terminal card payment intents always use 25% moms in the
API, independent of any client-supplied `tax_percentage`.
- Updates amount calculation, metadata persistence, stored-intent reuse
matching, the authoritative OpenAPI contracts, and operation-specific
Writerside outputs.
- Prevents double charging and false order closure across stale,
concurrently succeeded, partially recorded, or mismatched intents.
- Serializes payment create/capture/closure with order-item changes and
every order-to-invoice-collection reassignment through shared database
locks.
- Converts expected lock contention and reconciliation cases into
deliberate 409 responses.

## Exact-head evidence

Current head: `3a0f70d315a94d2efe586a2188d2c54f8ff11cd4`

- PHP syntax passed for all changed runtime files.
- Focused Orders suite: **42 tests / 293 assertions passed**.
- `git diff --check` passed.
- Fresh exact-head Tests and Qodana are running.
- Every Codex finding has a concrete reply; a fresh exact-head review is
requested below.

## Safety behavior

- Caller-controlled VAT is absent from request contracts; fixed 25% moms
is server-owned.
- A succeeded payment is preserved, requires the full expected
`amount_received`, and cannot close a changed/mismatched or
already-claimed collection.
- A compatible partially recorded Stripe closure is completed
idempotently; conflicting partial state fails closed for manual
reconciliation.
- Every cancellation/delete caller honors a concurrent-success result
and never falsely reports a completed payment as cleared.
- Price changes and invoice-collection reassignment share the payment
lock through validation, capture, post-capture reload, and closure.
- Reader changes are persisted only for reusable matching intents, so
stale intent cancellation targets the original terminal.
- Accepted legacy succeeded intents normalize stored tax to 25% before
response construction.

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-28 22:00:59 +02:00
Jeppe B da0113e3ed Auto-disable department self-serve at opening (#323)
Auto-disable department self-serve at opening
2026-07-28 18:03:05 +02:00
Jeppe BandJeppe Bundgaard 3c13892366 Harden subuser permission payloads (#328)
## Summary
- Normalize subuser grant permission payload keys before enum validation
so mixed-case customer-facing writes are accepted and deduped
consistently.
- Add a focused subuser route static check for permission payload
normalization.

## Verification
- `php
services/nginx/app/tests/subusers/SubusersRoutePermissionLinkTest.php &&
php
services/nginx/app/tests/subusers/SubusersRoutePermissionsPayloadTest.php`
- `php -l services/nginx/app/routes/subusersRoute.php && php -l
services/nginx/app/tests/subusers/SubusersRoutePermissionsPayloadTest.php`

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-28 17:06:29 +02:00
Jeppe B 30d860fdef Retire payment links and capture card payments (#327)
## Summary

- retire Stripe hosted payment-link creation routes used by POS and
order management
- automatically capture authorized payment intents rather than requiring
a separate manual capture action
- preserve ordinary terminal payment and payment-intent lifecycle
behavior
- add API and wiring regressions for payment-link retirement and
automatic capture

Paired frontend change:
https://github.com/copenhagentruckwash/pleno-vue/pull/233

## Verification

- focused backend unit suite: 2 tests, 36 assertions passed
- PHP syntax checks passed
- paired frontend unit and Playwright suites passed locally
- full required GitHub runner suites are required before this task may
enter Review or merge

## Security and operational notes

- no credentials, terminal secrets, or payment data are added
- no live Stripe account or physical terminal was exercised locally
- automatic merge remains gated on both paired PRs having passing
required checks and current branches
2026-07-27 19:09:37 +02:00
Jeppe BandJeppe Bundgaard d3e4798b11 Complete subuser notification and recovery lifecycle (#325)
## Summary

- notify customers by SMS with approve/deny links when a subuser
requests access
- notify subusers by SMS after approval or denial, including manual
grant changes
- support subuser password reset and authenticated password changes
- add read-only token previews followed by explicit POST confirmation
- store short-lived one-time purpose-bound action tokens only as SHA-256
digests
- serialize grant decisions transactionally to prevent conflicting
concurrent actions
- document the API contract in OpenAPI

## Security

- generic reset responses reduce account enumeration
- URL tokens are removed from browser history after frontend bootstrap
- approval previews never mutate state
- concurrent decisions lock the exact grant row
- SMS failures remain non-fatal and are returned as delivery status

Residual risk: existing subuser sessions cannot all be centrally
invalidated after password reset because there is no per-subuser session
index; they expire normally within the existing session lifetime.

## Verification

- backend Pest: 14 tests, 91 assertions
- PHP syntax checks passed
- focused PHPStan passed
- OpenAPI YAML parsed successfully
- `git diff --check` passed

Database-backed API integration tests were unavailable because the local
environment lacks the required database configuration.

## Paired delivery

Paired Frontend PR:
https://github.com/copenhagentruckwash/pleno-vue/pull/231

Both PRs are required before completion. The frontend PR contains the
responsive visual comparisons.

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-27 18:40:40 +02:00
Jeppe B 8d8f0eccce Fix cron worker minute cadence (#324)
Replace drifting cron loops with persistent cadence-tracked workers and CI-verifiable Compose wiring.
2026-07-27 18:16:09 +02:00
Jeppe B 6e24718c1f Isolate backend Docker jobs on ephemeral runners (#322)
## Summary
- Run Docker-producing PHP, Edge Broker, and Edge Gateway jobs on
ephemeral Ubuntu workspaces for both PR and push events.
- Keep the non-Docker Edge Agent and Release Manager gate on the trusted
backend pool.
- Preserve the explicit system-socket selection and fail-closed Docker
access check.

## Root cause
Exact-master run 29942825210 got past Docker access, then later jobs
failed during checkout because an earlier container left
`services/php/logs/error.log` root-owned in the reused self-hosted
workspace. This is workspace contamination, not a product-test failure.
The shallow frontend repair pattern would miss the depth-4 file and
would accumulate undeletable trash directories.

## Verification
- Workflow YAML parse passed.
- `git diff --check` passed.
- PR CI must be green; after merge, exact-master Required CI and the
non-skipped Release Manager gate are mandatory.
2026-07-22 19:47:31 +02:00
Jeppe B 9b481e0957 Use the system Docker socket in backend CI (#321)
## Summary
- Keep untrusted PRs on ephemeral Ubuntu runners and trusted pushes on
the local backend pool.
- Force Docker-dependent jobs to the working system socket instead of
the unavailable default rootless context.
- Preserve the fail-closed Docker access check and never chmod the
socket.

## Evidence
- Exact master run 29942048689 failed before tests because plain Docker
commands resolved to `/run/user/1000/docker.sock`.
- Backend listener processes already have docker-group membership;
`/var/run/docker.sock` is root:docker 0660.
- `DOCKER_HOST=unix:///var/run/docker.sock docker version` succeeds
locally with server 29.3.1.
- Workflow YAML parse and `git diff --check` pass.

Exact-master Required CI and Release Manager gate success remain
mandatory after merge.
2026-07-22 19:33:13 +02:00
Jeppe B 0060fb45ca Add in-app account deletion (#319)
## Summary
- Add self-service deletion for the authenticated customer or subuser
identity only.
- Preserve shared customer grants, reset keys, bookings, order bookings,
vehicles, invoices, and legally required history.
- Require password/TOTP or a fresh deletion-specific, five-minute,
single-use WebAuthn assertion.
- Reject support impersonation and expired legacy plain-session tokens.
- Use durable database throttling, transactional request processing, a
durable outbox, and terminal `manual_review` state.
- Keep API and worker default-off behind separate
`account_deletion.api_enabled` and `account_deletion.worker_enabled`
module-config flags.

## Safe rollout
1. Keep both flags disabled.
2. Run `php scripts/account-deletion-schema.php check`.
3. If needed, run `php scripts/account-deletion-schema.php apply --yes`,
then rerun `check` until `ready:true`.
4. Deploy the frontend companion PR while the API remains disabled.
5. Enable `api_enabled` for a controlled canary; verify password and
passwordless request flows plus immediate authentication revocation.
6. Inspect queued request/outbox state, then enable `worker_enabled`.
7. Verify anonymization, preserved tenant/history data, outbox delivery,
retries, and manual-review behavior before broad rollout.

## Verification
- Account deletion unit tests: 2 passed, 43 assertions.
- PHP lint, both OpenAPI YAML parses, runtime-DDL scan,
destructive-scope scan, and `git diff --check` passed.
- Full API/unit/integration evidence is required from exact-head CI;
local Docker is unavailable and shared-vendor tests were explicitly
discarded.

## Security notes
- Schema mutation is CLI-only; web and cron paths perform read-only
readiness checks.
- Runtime behavior fails closed when schema/config/throttle/delivery
prerequisites are unavailable.
2026-07-22 19:22:17 +02:00
Jeppe B 34cf804d75 Harden CI runner and release gate security (#320)
## Summary

- run untrusted pull-request jobs on ephemeral `ubuntu-24.04` runners
- reserve the local backend runner pool for trusted branch pushes
- remove world-writable Docker-socket fallbacks
- pin core GitHub Actions and disable checkout credential persistence
- remove the release-manager PHP parse-error fail-open path

## Why

Pull-request code previously ran on persistent self-hosted runners with
Docker access, and CI contained permission weakening and a release-gate
break-glass success path. Those behaviors were unsafe for autonomous
intake.

## Validation

- workflow YAML parsed
- backend AI workflow outputs are in sync
- pinned action SHAs match the current v4 tags
- `git diff --check`

## Risk and activation

This is an R4 CI/release-policy change. Keep the PR draft for human
review and let required CI prove the hosted-runner path before merge.
2026-07-22 19:05:55 +02:00
Jeppe B 677d4700b0 Enforce customer product restrictions for order bookings (#318)
## What changed

- validate every normalized order-booking item against active customer
product rules before reservation and persistence
- return a structured HTTP 400 response containing the rejected product
and matching rule metadata
- document the rejection response in both OpenAPI specifications
- add API coverage for restricted base products, restricted add-ons, and
allowed neighboring products

## Why

Frontend rule guidance alone cannot prevent stale or crafted requests
from persisting restricted booking products. The booking write boundary
must enforce the same customer rules.

## Validation

- full backend API suite
- focused order-booking API coverage
- PHP syntax checks
- OpenAPI and diff checks

## Related frontend PR

The coordinated frontend PR provides fail-closed selection, recovery,
and responsive booking-page behavior.
2026-07-20 14:09:20 +02:00
Jeppe B abde54c898 Allow Capacitor iOS API origin (#317)
Allow the exact Capacitor iOS WebView origin through credentialed CORS while strictly validating request-origin syntax.
2026-07-20 13:53:55 +02:00
Jeppe B b177347bf5 Ignore PHPUnit result cache (#316)
Ignore the generated PHPUnit cache directory and remove its volatile test-results file from Git tracking so test runs no longer dirty protected branch checkouts.
2026-07-20 08:49:27 +02:00
Jeppe B 32a5b99204 Resolve remaining backend full-scan Qodana findings (#315)
Fix the two High findings exposed by the first full master Qodana scan after the broader remediation.
2026-07-17 06:24:47 +02:00
Jeppe B 2a6a86c9c3 Resolve backend Qodana critical and high findings (#314)
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
2026-07-17 05:44:16 +02:00
Jeppe B 6566027746 Configure advisory Qodana analysis (#313)
## Summary

- configure advisory Qodana PHP 2026.1 analysis for trusted pull
requests and master, beta, canary, and internal branch scans
- install both Composer projects and the edge-agent/edge-broker Node
dependencies before analysis
- exclude generated, vendor, build, cache, legacy-test, and local-agent
trees
- keep Quick Fixes, SARIF artifacts, baselines, thresholds, and
required-check enforcement disabled during calibration

## Safety

- fails closed when QODANA_TOKEN is absent
- skips draft, fork, and Dependabot pull requests
- uses least-privilege GitHub permissions and immutable action SHAs
- uploads findings to the dedicated api Qodana Cloud project

## Validation

- actionlint 1.7.12
- SchemaStore qodana-1.0 validation
- bootstrap shell syntax and lockfile structure checks
- immutable action tag verification
- git diff --check
- independent review completed with no findings

## Live verification

- [PR-mode
scan](https://github.com/copenhagentruckwash/api/actions/runs/29494056175)
completed successfully with 0 changed-file problems, 439 inspections,
and a passed license audit ([Qodana
report](https://qodana.cloud/projects/P2nXd/reports/LJv98e))
- [full branch
scan](https://github.com/copenhagentruckwash/api/actions/runs/29495399119)
completed successfully and uploaded 8,248 current findings across 725
files, 439 inspections, and a passed license audit to the dedicated api
project ([Qodana
report](https://qodana.cloud/projects/P2nXd/reports/qJMOxX))
- the initial debt remains advisory; baseline and required-check
enforcement are intentionally deferred until findings are triaged
2026-07-16 14:35:59 +02:00
Jeppe B 511605b619 Verify API master branch protection (#312)
Record the live ruleset and complete the protected-path canary.
2026-07-16 12:55:18 +02:00
Jeppe B c2abf17cd7 Prepare API default branch protection (#311)
Add a stable Required CI gate, branch-protection desired state, and publishing runbook.
2026-07-16 12:38:01 +02:00
Jeppe B fefe18a719 Fix legacy customer attribute session query 2026-07-16 12:20:20 +02:00
Jeppe B 9b2d5d5291 Fix customer restriction CI regressions 2026-07-16 12:06:42 +02:00
Jeppe B e1fb79d9b6 Add customer rule product restrictions 2026-07-16 11:50:52 +02:00
Jeppe B 879dfcf79a Improve invoice period data and POS add-on validation 2026-07-15 17:04:52 +02:00
Jeppe B 0feb705059 Support collected invoice economic PDF downloads 2026-07-14 15:39:39 +02:00
Jeppe Bundgaard 69b3bf83c4 Allow custom subuser grant permission writes 2026-07-13 22:30:18 +02:00
Jeppe Bundgaard 5bac316e4b Add loading screen for session confirmation and enhance vehicle management labels 2026-07-13 22:11:54 +02:00
Jeppe Bundgaard 233133365d Guard chauffeur vehicles on legacy schemas 2026-07-13 20:03:50 +02:00
Jeppe Bundgaard 026492c3bd Queue cron runs through workers 2026-07-13 20:03:39 +02:00
Jeppe Bundgaard 403f93e62c Fix self-serve schema compatibility in CI 2026-07-13 15:36:03 +02:00
Jeppe Bundgaard 582edd3e6c Implement subuser verification and invoice/self-serve API fixes 2026-07-13 15:11:49 +02:00
Jeppe Bundgaard a4fafaf7fb Enhance subuser management functionality and improve UI responsiveness 2026-07-13 10:25:26 +02:00
Jeppe Bundgaard 327e9cf817 Update self-serve permissions and enhance UI components for customer interactions 2026-07-13 10:21:22 +02:00
Jeppe Bundgaard 012e5366ba Add system status displays for Minio and Redis, and enhance backup configuration 2026-07-13 10:08:00 +02:00
Jeppe B fa1ade555f Fix self-serve cron registry test
Fix self-serve cron registry test
2026-07-09 11:36:11 +02:00
Jeppe B 7a1c444df0 Activate self-serve opening relays
Activate self-serve opening relays
2026-07-09 11:27:32 +02:00
Jeppe Bundgaard 6a00f023b1 Refactor cron scheduling 2026-07-09 11:04:13 +02:00
Jeppe B 8aefbd8fb3 Guard wash subscription distribution SQL
Guard the wash subscription distribution query after invoice-inclusion filtering removes all candidate orders, preventing an empty IN () clause on the invoicing distribution endpoint.

Verified with focused syntax, Pest, PHPStan, and invoicing unit-suite checks.
2026-07-09 10:24:02 +02:00
Jeppe B a7181a4ab2 Fix edge gateway relay binding reactivation
Reactivate existing relay binding rows when a gateway/relay pair is re-added after soft deletion, avoiding duplicate uniq_edge_gateway_binding inserts. Add regression coverage for the reactivation path.
2026-07-08 18:46:33 +02:00
Jeppe Bundgaard fc6c76ad1b Add customer rule cleanup for spotfree addon products with preview and apply functionality 2026-07-08 14:40:46 +02:00
Jeppe Bundgaard 6a694f92cc Add bulk action preview and apply endpoints for collected invoices 2026-07-08 13:46:31 +02:00
Jeppe Bundgaard b7a2dc04d7 Merge remote-tracking branch 'origin/master' 2026-07-08 13:03:49 +02:00
Jeppe Bundgaard 870b88e707 Add assertError method to ApiResponse and enhance CI test failure handling 2026-07-08 13:03:33 +02:00
Jeppe B e14cddc1fb Fix API suite regressions 2026-07-08 12:54:26 +02:00
Jeppe B 31887fa8c9 Cover API CI skip prevention wiring 2026-07-08 12:32:27 +02:00
Jeppe B eac83b18a0 Preflight required extensions for API CI 2026-07-08 12:32:19 +02:00
Jeppe B 3817a37021 Make API CI fail on skipped bootstrap 2026-07-08 12:31:40 +02:00
Jeppe Bundgaard 940a3e5e9b Refactor superuser grant selection logic to prioritize the most recently updated grant 2026-07-08 12:16:22 +02:00
Jeppe Bundgaard 3221223865 Enhance vehicle summary retrieval for customers and update OpenAPI schema for subuser management grants 2026-07-08 12:15:18 +02:00
Jeppe Bundgaard b51006d9d1 Refactor subuser management payload and enhance grant deduplication logic 2026-07-08 12:13:12 +02:00
Jeppe Bundgaard 6b7592921d Add subuser permission templates service and related tests 2026-07-08 11:49:40 +02:00
Jeppe Bundgaard f26a427510 Add test for customer booking sessions to retrieve single-product final pricing with context 2026-07-08 10:41:56 +02:00
Jeppe Bundgaard 6de747252f Add department pricing normalization for order bookings 2026-07-08 10:35:59 +02:00
Jeppe Bundgaard ff225ff5e7 Refactor getTargetItems method to include customer and department parameters for improved item normalization 2026-07-08 10:35:39 +02:00
Jeppe Bundgaard dcef993f12 Implement department wash count service and refactor related reporting functions 2026-07-08 10:35:24 +02:00
Jeppe Bundgaard 23aca449f7 Add department wash count service for tracking wash counts and transaction summaries 2026-07-08 10:25:03 +02:00
Jeppe Bundgaard 31b5ba136a Add user-scoped routes for managing subusers and their grants 2026-07-08 10:24:50 +02:00
Jeppe Bundgaard f0b5479f30 Enhance department pricing functionality and improve related tests 2026-07-08 09:35:40 +02:00
Jeppe B b77efc538a Fix backend test gates and department product access 2026-07-07 22:16:37 +02:00
Jeppe Bundgaard 10d1eb5bac Refactor system search cache handling and update OpenAPI specifications for max results 2026-07-07 18:59:07 +02:00
Jeppe Bundgaard 084435e9b8 Add customer pricing permissions and update related tests 2026-07-07 17:58:39 +02:00
Jeppe Bundgaard 172a21c517 Implement department-specific customer pricing functionality 2026-07-07 17:27:56 +02:00
Jeppe Bundgaard c24428e4c7 Update ProductsApiTest to use 'user' session for department pricing tests 2026-07-07 14:38:41 +02:00
Jeppe Bundgaard bf1d6a583e Allow customers to read own attributes 2026-07-07 13:05:24 +02:00
Jeppe Bundgaard 08ac16e665 Fix customer wash certificate access and emails 2026-07-07 12:37:19 +02:00
Jeppe Bundgaard 79185a3c76 Enhance product listing for customer booking sessions: allow access to booking-visible products without requiring additional permissions 2026-07-07 12:23:56 +02:00
Jeppe Bundgaard 3a730e3507 Merge limited backoffice role permission templates 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard 2ae1fc3fcf Allow customer order booking creation without booking permission 2026-07-06 16:33:31 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe B f262047476 Merge pull request #300 from copenhagentruckwash/codex/scoped-monthly-split-api
Scope monthly invoice split API
2026-07-06 16:01:54 +02:00
Jeppe B b8390ac0d3 Merge pull request #298 from copenhagentruckwash/codex/only-tankcleaning-order-enforcement
Enforce only tankcleaning order products
2026-07-06 16:01:40 +02:00
Jeppe B 0d4a5470e5 Add superuser department overview API (#301)
Merge backend API for the superuser department overview.
2026-07-06 16:01:04 +02:00
Jeppe B 845ca6e48e Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
2026-07-06 15:31:27 +02:00
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe Bundgaard 9f797bf6b8 Add opening hours table to API test schema 2026-07-06 14:27:39 +02:00
Jeppe Bundgaard d345db927f Add daily report table to API test schema 2026-07-06 14:16:41 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe Bundgaard eca7a81f9d Add superuser department overview API 2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe Bundgaard 430c90cbca Enforce only tankcleaning order products 2026-07-06 12:53:42 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
521 changed files with 59733 additions and 8307 deletions
+56
View File
@@ -0,0 +1,56 @@
# Default branch protection
`master` is changed through pull requests. Do not push or publish directly to
the default branch, including through automation or the Git Data API.
## Normal publishing flow
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
2. Commit and push only the intended changes.
3. Open a pull request targeting `master`.
4. Wait for the `Required CI` check. If `master` moves, update the branch and
wait for the strict check to rerun.
5. Resolve every review conversation and squash-merge the pull request.
6. Confirm the post-merge `Release Manager gate` completes on `master`.
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
the Release Manager gate is intentionally post-merge.
## Desired ruleset
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
is the importable final desired-state repository-ruleset request body. For the
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
the normalized ruleset and verify a green preparation PR and post-merge run,
then PUT the exact committed file to activate it.
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
approvals, conversation resolution, strict `Required CI` from GitHub Actions
integration `15368`, squash-only linear history, and blocks deletion and force
pushes. Repository administrators receive pull-request-only bypass; they do not
receive a standing direct-push bypass.
When the ruleset is activated, align repository settings at the same time:
retain squash merging, disable merge commits and rebase merging, enable
auto-merge and branch-update suggestions, delete merged branches automatically,
keep the Actions token read-only, and prevent Actions from approving reviews.
## Activation record
Repository ruleset `19041620` was activated on 2026-07-16 after preparation
PR #311 passed `Required CI` and the merged `master` commit passed both
`Required CI` and the `Release Manager gate`. This documentation update is
the after-activation canary for the normal protected pull-request path.
## Break glass
When an incident cannot wait for the normal gate:
1. Open a pull request and describe the incident, risk, and reason for bypass.
2. Have a repository administrator use the pull-request-only bypass.
3. Monitor `Required CI` and the post-merge Release Manager workflow.
4. Open a follow-up pull request for any deferred validation or remediation.
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
emergency bypasses must remain visible in GitHub's audit trail.
@@ -0,0 +1,57 @@
{
"name": "Protect default branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"conditions": {
"ref_name": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "required_linear_history"
},
{
"type": "pull_request",
"parameters": {
"allowed_merge_methods": [
"squash"
],
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_approving_review_count": 0,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "Required CI",
"integration_id": 15368
}
],
"strict_required_status_checks_policy": true
}
}
]
}
+55 -33
View File
@@ -1,52 +1,74 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
branches:
- master
- beta
- canary
- internal
types:
- opened
- reopened
- synchronize
- ready_for_review
push:
branches: # Specify your branches here
- main # The 'main' branch
- 'releases/*' # The release branches
branches:
- master
- beta
- canary
- internal
concurrency:
group: qodana-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
qodana:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
checks: read
checks: write
pull-requests: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
fetch-depth: 0 # a full history is required for pull request analysis
persist-credentials: false
- name: Mark repository as safe for Git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Prepare Qodana cache directories
run: |
mkdir -p "${RUNNER_TEMP}/qodana/caches"
mkdir -p "${RUNNER_TEMP}/qodana/results"
- name: Detect Qodana Cloud token
id: qodana-token
- name: Require Qodana Cloud token
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
shell: bash
run: |
if [ -n "${QODANA_TOKEN:-}" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
set -euo pipefail
if [[ -z "${QODANA_TOKEN}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: 'Qodana Scan'
if: ${{ steps.qodana-token.outputs.present == 'true' }}
uses: JetBrains/qodana-action@v2026.1
- name: Check out the analyzed commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
pr-mode: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Run Qodana
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891 # v2026.1.3
with:
pr-mode: ${{ github.event_name == 'pull_request' }}
use-caches: true
cache-default-branch-only: true
use-annotations: true
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
upload-result: false
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }}
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
+167
View File
@@ -0,0 +1,167 @@
name: Deploy to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual deploy'
required: false
default: 'manual'
concurrency:
group: deploy-${{ github.repository }}
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: CI + Deploy
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show commit info
run: |
echo "Repo: ${{ github.repository }}"
echo "Branch: ${{ github.ref }}"
echo "Commit: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
# === CI (phpunit / vitest) runs here via repo's existing CI config ===
# (Most of our repos already have a "Required CI" check; this section
# would invoke that. If your repo doesn't have a CI workflow, the
# required-check on the branch will block this workflow's deploy step.)
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git rev-parse HEAD > /tmp/last_deploy_sha
echo "PRE_SHA=$(cat /tmp/last_deploy_sha)"
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git fetch origin master
git reset --hard origin/master
# PHP repos: composer install + clear cache
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
php artisan cache:clear || true
php artisan config:cache || true
# Restart php-fpm if used
sudo systemctl reload php8.2-fpm || true
fi
# Node repos: npm ci + build
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
# Restart node service
sudo systemctl reload pleno-vue || sudo systemctl reload nginx || true
fi
# Restart generic services
sudo systemctl reload nginx || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Pre-deploy schema check (run all *_schema_bootstrap)
id: pre_schema
run: |
echo "Running schema bootstraps against the live database…"
# Idempotent — adds missing columns, never drops anything.
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
# production failure mode (TRU-77) where migrations were
# merged to master but never applied to the live DB.
php scripts/run-schema-bootstraps.php
echo "Schema bootstraps complete."
- name: Alert Slack if schema-check fails (pre-deploy)
if: failure()
run: |
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
https://slack.com/api/chat.postMessage \
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
- name: Smoke test
id: smoke
continue-on-error: true
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
# Also hit the new admin schema-check endpoint to verify
# no required columns are missing.
echo "::group::Schema health check"
php scripts/schema-health-check.php | tee /tmp/schema-report.json
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
echo "::error::Schema health check FAILED — missing columns:"
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
exit 1
fi
echo "Schema health check OK."
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure'
run: |
echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git reset --hard ${{ steps.pre.outputs.pre_sha }}
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
sudo systemctl reload php8.2-fpm || true
fi
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
fi
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v1.27.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.repository }}@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke test passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Update Linear issue
if: success() && steps.deploy.outcome == 'success'
run: |
# Find Linear issues in this commit's history and post a comment
# (uses GitHub's auto-link: if PR body contains "TRU-123" it auto-links)
# We skip this here; the OpenClaw cron `f26dfd83` handles Linear updates.
echo "Deploy notification will be picked up by OpenClaw cron."
+84 -52
View File
@@ -3,39 +3,49 @@ name: Tests
on:
pull_request:
push:
branches:
- master
- beta
- canary
- internal
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
php:
name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
# Docker jobs use disposable workspaces so root-owned container artifacts cannot poison later checkouts.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
suite: [unit, integration, api, legacy]
env:
DOCKER_HOST: unix:///var/run/docker.sock
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
docker ps >/dev/null 2>&1 || {
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
exit 1
}
- name: Setup Node.js
if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -49,7 +59,7 @@ jobs:
- name: Upload PHP suite logs
if: ${{ failure() }}
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: php-${{ matrix.suite }}-logs
path: .tmp/ci-logs/${{ matrix.suite }}
@@ -58,14 +68,16 @@ jobs:
edge-agent:
name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, pleno, backend]
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -103,25 +115,23 @@ jobs:
edge-broker:
name: Edge Broker (required)
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
runs-on: ubuntu-24.04
env:
DOCKER_HOST: unix:///var/run/docker.sock
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
docker ps >/dev/null 2>&1 || {
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
exit 1
}
- name: Materialize CI compose env files
run: |
@@ -135,7 +145,7 @@ jobs:
docker compose -f docker-compose.example.yml config > /dev/null
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -149,8 +159,9 @@ jobs:
edge-gateway-backend:
name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
runs-on: ubuntu-24.04
env:
DOCKER_HOST: unix:///var/run/docker.sock
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
@@ -163,21 +174,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
docker ps >/dev/null 2>&1 || {
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
exit 1
}
- name: Allocate CI ports
run: |
@@ -228,7 +235,7 @@ jobs:
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -336,11 +343,42 @@ jobs:
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
required-ci:
name: Required CI
runs-on: ubuntu-latest
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ always() }}
steps:
- name: Verify required jobs succeeded
env:
PHP_RESULT: ${{ needs.php.result }}
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
run: |
set -euo pipefail
failed=0
for dependency in \
"php=${PHP_RESULT}" \
"edge-agent=${EDGE_AGENT_RESULT}" \
"edge-broker=${EDGE_BROKER_RESULT}" \
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
do
name="${dependency%%=*}"
result="${dependency#*=}"
if [ "$result" != "success" ]; then
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
failed=1
fi
done
test "$failed" -eq 0
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
runs-on: ubuntu-24.04
needs: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
steps:
- name: Record Release Manager API gate
@@ -368,12 +406,6 @@ jobs:
exit 0
fi
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
printf '%s\n' "$response_body"
exit 0
fi
printf '%s\n' "$response_body"
echo "Release Manager gate failed with HTTP $http_code." >&2
exit 1
+1
View File
@@ -2,6 +2,7 @@
/docker-compose.yml
/services/nginx/app/vendor/
/services/nginx/app/modules/washcertificates/vendor/
/services/nginx/app/.phpunit.cache/
/services/nginx/letsencrypt/
*.pem
*.log.gz
+5
View File
@@ -24,6 +24,7 @@ RUN set -eux; \
libzip-dev \
mariadb-client \
nginx \
openssl \
pkg-config \
redis-tools \
unzip \
@@ -46,6 +47,8 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/ /var/www/html/
COPY scripts/bird-control-plane-activate.php /var/www/html/scripts/bird-control-plane-activate.php
COPY scripts/bird-control-plane-auto-activate.php /var/www/html/scripts/bird-control-plane-auto-activate.php
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
@@ -61,6 +64,8 @@ RUN set -eux; \
fi; \
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
php -r 'exit(function_exists("proc_open") && extension_loaded("openssl") ? 0 : 1);'; \
test "$(openssl pkey -pubin -in /var/www/html/modules/bird/resources/control-plane-bootstrap-public.pem -outform DER | sha256sum | cut -d " " -f 1)" = "6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21"; \
chown -R www-data:www-data /var/www/html; \
chmod -R 755 /var/www/html
+5
View File
@@ -2,6 +2,11 @@
Backend API for Copenhagen Truck Wash services.
Changes are published from a scoped feature branch through a pull request to
`master`; direct default-branch pushes are not part of the release workflow.
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
and emergency procedure.
## Architecture & Stack
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
+1 -1
View File
@@ -31,7 +31,7 @@ $MINIO = [
'access_key' => '', // Minio access
'secret_key' => '' // Minio secret key
];
$SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
$SLACK_DEFAULT_WEBHOOK = ''; // Set through SLACK_DEFAULT_WEBHOOK; never commit a production webhook URL.
$REDIS_CONFIG = [
'host' => '', // Redis host (IP address)
'user' => '', // Redis user
+1 -1
View File
@@ -129,7 +129,7 @@ services:
- redis
- mysql
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
command: ["php", "index.php", "run", "cron-worker"]
env_file:
- .env.example
environment:
+1 -1
View File
@@ -367,7 +367,7 @@ services:
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
command: ["php", "index.php", "run", "cron-worker"]
env_file:
- .env
environment:
+1 -1
View File
@@ -425,7 +425,7 @@ services:
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
command: ["php", "index.php", "run", "cron-worker"]
env_file:
- .env
environment:
+4
View File
@@ -0,0 +1,4 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
+1 -3
View File
@@ -9856,6 +9856,7 @@
"Orders"
],
"summary": "Create Stripe payment intent",
"description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.",
"operationId": "createStripePaymentIntent",
"requestBody": {
"required": true,
@@ -9873,9 +9874,6 @@
},
"reader": {
"type": "string"
},
"tax_percentage": {
"type": "integer"
}
}
}
+1
View File
@@ -7,4 +7,5 @@
<!-- AUTO-GENERATED, DO NOT EDIT -->
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
</topic>
@@ -12,7 +12,7 @@
</chapter>
<chapter title="Operation" id="operation">
<p>Operation ID: <code>createStripePaymentIntent</code></p>
<p>Create Stripe payment intent</p>
<p>Creates a Stripe Terminal card payment intent with fixed 25% moms.</p>
</chapter>
<chapter title="Authentication" id="authentication">
<p>Security requirements:</p>
@@ -32,9 +32,6 @@
},
&quot;reader&quot;: {
&quot;type&quot;: &quot;string&quot;
},
&quot;tax_percentage&quot;: {
&quot;type&quot;: &quot;integer&quot;
}
},
&quot;required&quot;: [
+95
View File
@@ -0,0 +1,95 @@
# XL Vask Selvvask surface — inventory & simplification plan
## Scope
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
operator-facing review and order-creation flow.
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
## Files removed
| Path | Reason |
| --- | --- |
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
## Code changes (kept & simplified)
| Path | Change |
| --- | --- |
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
## New operator-facing endpoints
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
users see only their group's halls).
| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
## Permissions
The Selvvask surface uses these permissions only:
- `list_xlvask_usage_orders_own`
- `list_xlvask_usage_orders_all`
- `review_xlvask_usage_order`
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
`superuser_xlvask_automation_activate` are not referenced anywhere in the
slimmed surface.
## Persistence model
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
columns — no migration required for the simplified flow.
`orders_o::selectByWashId(int|string $WashId)` and
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
the only integration points with the order pipeline.
## Tests
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
## Repo scope
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
this session and will be handled in a follow-up PR.
+1991 -107
View File
File diff suppressed because it is too large Load Diff
+49 -40
View File
@@ -1,46 +1,55 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
linter: jetbrains/qodana-php:2026.1
profile:
name: qodana.starter
name: qodana.recommended
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
php:
version: "8.2"
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
bootstrap: |+
set -eu
composer --working-dir=services/nginx/app install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
composer --working-dir=services/nginx/app/modules/washcertificates install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
npm --prefix services/edge-agent ci --ignore-scripts
npm --prefix services/edge-broker ci --ignore-scripts
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-php:2025.3
exclude:
# This application is intentionally Composer-classmapped and keeps legacy snake_case
# classes plus multiple local test doubles in single files; PSR path rules do not apply.
- name: PhpIllegalPsrClassPathInspection
paths:
- services/nginx/app
# Unit-test doubles intentionally bypass integration-heavy parent constructors.
- name: PhpMissingParentConstructorInspection
paths:
- services/nginx/app/tests
# These focused tests configure doubles through public fields before invoking behavior.
- name: PhpObjectFieldsAreOnlyWrittenInspection
paths:
- services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php
- services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php
- services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php
# API coverage markers are intentional statement-style calls in the Pest DSL.
# Their return value is irrelevant; the call records route/scenario coverage.
- name: PhpExpressionResultUnusedInspection
paths:
- services/nginx/app/tests/Api
- name: All
paths:
- services/nginx/app/vendor
- services/nginx/app/modules/washcertificates/vendor
- services/nginx/app/build
- services/nginx/app/.phpunit.cache
- services/nginx/app/tests/Legacy
- services/edge-agent/node_modules
- services/edge-broker/node_modules
- services/edge-agent/dist
- documentation/generated
- documentation/topics/generated
- documentation/_build
- documentation/_site_rebuild_20260317
- docs_bird_voice_calls.html
- .tmp
- .openclaw
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
const WD = __DIR__ . '/../services/nginx/app';
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/classes/account_deletion_schema_bootstrap.php';
$response = null;
$db = new \classes\db($CONFIG_DB);
$db->connect();
$command = $argv[1] ?? 'check';
if ($command === 'apply') {
if (($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
exit(2);
}
\classes\account_deletion_schema_bootstrap::apply();
}
if (!in_array($command, ['check', 'apply'], true)) {
fwrite(STDERR, "Usage: scripts/account-deletion-schema.php check|apply --yes\n");
exit(2);
}
$status = \classes\account_deletion_schema_bootstrap::check();
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit($status['ready'] ? 0 : 1);
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
$command = $argv[1] ?? 'check';
if (!in_array($command, ['check', 'apply', 'webhooks-check', 'webhooks-apply'], true)) {
fwrite(
STDERR,
"Usage: scripts/bird-control-plane-activate.php check|apply|webhooks-check|webhooks-apply [--yes]\n"
);
exit(2);
}
if (in_array($command, ['apply', 'webhooks-apply'], true) && ($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing Bird activation without: apply --yes\n");
exit(2);
}
$appDirectory = __DIR__ . '/../services/nginx/app';
if (!is_file($appDirectory . '/config.php')) {
$appDirectory = dirname(__DIR__);
}
define('WD', $appDirectory);
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
require_once WD . '/modules/bird/classes/bird_webhook_subscription_reconciler.php';
try {
$pdo = \classes\db::getPDO();
if (str_starts_with($command, 'webhooks-')) {
$reconciler = new \bird\classes\bird_webhook_subscription_reconciler($pdo);
$organizationId = trim((string)(getenv('BIRD_ORGANIZATION_ID') ?: ''));
$status = $command === 'webhooks-apply'
? $reconciler->apply($organizationId)
: $reconciler->check($organizationId);
} else {
$activator = new \bird\classes\bird_control_plane_activator($pdo);
$status = $command === 'apply' ? $activator->apply([
'controlPlaneToken' => trim((string)(getenv('BIRD_CONTROL_PLANE_TOKEN') ?: '')),
'webhookSigningKey' => trim((string)(getenv('BIRD_WEBHOOK_SIGNING_KEY') ?: '')),
'participantId' => trim((string)(getenv('BIRD_PARTICIPANT_ID') ?: '')),
]) : $activator->check();
}
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit(($status['ready'] ?? false) === true ? 0 : 1);
} catch (Throwable $throwable) {
error_log('[bird-control-plane-activate] Failed: ' . get_class($throwable));
fwrite(STDOUT, json_encode([
'ready' => false,
'errorCode' => 'bird_activation_failed',
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit(1);
}
@@ -0,0 +1,28 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
exit(2);
}
$appDirectory = __DIR__ . '/../services/nginx/app';
if (!is_file($appDirectory . '/config.php')) {
$appDirectory = dirname(__DIR__);
}
define('WD', $appDirectory);
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/modules/bird/classes/bird_control_plane_auto_activation.php';
try {
$status = (new \bird\classes\bird_control_plane_auto_activation(
\classes\db::getPDO()
))->run();
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit(($status['ready'] ?? false) === true ? 0 : 1);
} catch (Throwable $throwable) {
error_log('[bird-control-plane-auto-activate] Failed: ' . get_class($throwable));
fwrite(STDOUT, '{"ready":false,"errorCode":"bird_auto_activation_failed"}' . PHP_EOL);
exit(1);
}
@@ -0,0 +1,79 @@
#!/bin/sh
set -eu
bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'
status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'
expected_algorithm='RSA-OAEP-256'
expected_fingerprint='6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21'
private_key='/home/jeppe/.openclaw/credentials/bird.bootstrap-private.pem'
credential_dir='/home/jeppe/.openclaw/credentials'
destination="$credential_dir/bird.gateway-token"
umask 077
mkdir -p "$credential_dir"
envelope_file="$(mktemp "$credential_dir/.bird-bootstrap-envelope.XXXXXX")"
candidate_file="$(mktemp "$credential_dir/.bird-gateway-token.XXXXXX")"
payload_file="$(mktemp "$credential_dir/.bird-bootstrap-payload.XXXXXX")"
status_file="$(mktemp "$credential_dir/.bird-bootstrap-status.XXXXXX")"
cleanup() {
rm -f "$envelope_file" "$candidate_file" "$payload_file" "$status_file"
}
trap cleanup EXIT HUP INT TERM
test -r "$private_key"
test "$(stat -c '%a' "$private_key")" = '600'
curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--max-time 30 "$bootstrap_url" > "$envelope_file"
test "$(jq -r '.success // false' "$envelope_file")" = 'true'
test "$(jq -r '.data.algorithm // empty' "$envelope_file")" = "$expected_algorithm"
test "$(jq -r '.data.keyFingerprint // empty' "$envelope_file")" = "$expected_fingerprint"
jq -e '.data | keys == ["algorithm","ciphertext","keyFingerprint","tokenVersion","updatedAt"]' \
"$envelope_file" >/dev/null
jq -e '.data.tokenVersion | type == "number" and . >= 1 and floor == .' \
"$envelope_file" >/dev/null
jq -e '.data.updatedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")' \
"$envelope_file" >/dev/null
jq -e '.data.ciphertext | type == "string" and length == 512 and test("^[A-Za-z0-9+/]{512}$")' \
"$envelope_file" >/dev/null
jq -r '.data.ciphertext' "$envelope_file" \
| base64 -d \
| openssl pkeyutl -decrypt -inkey "$private_key" \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha256 \
-pkeyopt rsa_mgf1_md:sha256 > "$payload_file"
jq -e '. | keys == ["algorithm","keyFingerprint","token","tokenVersion","updatedAt"]' \
"$payload_file" >/dev/null
test "$(jq -r '.algorithm // empty' "$payload_file")" = "$expected_algorithm"
test "$(jq -r '.keyFingerprint // empty' "$payload_file")" = "$expected_fingerprint"
test "$(jq -r '.tokenVersion // empty' "$payload_file")" = \
"$(jq -r '.data.tokenVersion' "$envelope_file")"
test "$(jq -r '.updatedAt // empty' "$payload_file")" = \
"$(jq -r '.data.updatedAt' "$envelope_file")"
jq -j '.token' "$payload_file" > "$candidate_file"
test "$(wc -c < "$candidate_file")" = '64'
grep -Eq '^[A-Za-z0-9_-]{64}$' "$candidate_file"
chmod 600 "$candidate_file"
token="$(cat "$candidate_file")"
{
printf 'url = "%s"\n' "$status_url"
printf 'proto = "=https"\n'
printf 'tlsv1.2\n'
printf 'fail\nsilent\nshow-error\n'
printf 'max-time = 30\n'
printf 'header = "Authorization: Bearer %s"\n' "$token"
} | curl --config - > "$status_file"
unset token
jq -e '.success == true and .data.enabled == true and .data.webhookConfigured == true' \
"$status_file" >/dev/null
mv -f "$candidate_file" "$destination"
chmod 600 "$destination"
trap - EXIT HUP INT TERM
rm -f "$envelope_file" "$payload_file" "$status_file"
printf 'Bird gateway credential bootstrapped and authenticated.\n'
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
const WD = __DIR__ . '/../services/nginx/app';
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
$command = $argv[1] ?? 'check';
if (!in_array($command, ['check', 'apply'], true)) {
fwrite(STDERR, "Usage: scripts/bird-control-plane-schema.php check|apply --yes\n");
exit(2);
}
$pdo = \classes\db::getPDO();
if ($command === 'apply') {
if (($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
exit(2);
}
\bird\classes\bird_control_plane_schema_bootstrap::apply($pdo);
}
$status = \bird\classes\bird_control_plane_schema_bootstrap::check($pdo);
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit($status['ready'] ? 0 : 1);
+1 -1
View File
@@ -144,7 +144,7 @@ function requestJson({ method = "GET", port, path: requestPath, body = null, hea
raw += chunk;
});
response.on("end", () => {
let decoded = {};
let decoded;
try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch {
+19 -24
View File
@@ -95,7 +95,7 @@ function directCaddyBaseUrl(baseUrl) {
}
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
@@ -227,11 +227,7 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
return true;
}
if (/already exists|already connected/i.test(stderr)) {
return true;
}
return false;
return /already exists|already connected/i.test(stderr);
}
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
@@ -833,7 +829,7 @@ async function main() {
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
} catch (error) {
let operationSnapshot = null;
let operationSnapshot;
try {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
@@ -928,22 +924,20 @@ async function main() {
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
@@ -961,9 +955,10 @@ async function main() {
allowFailure: true,
}).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) {
const fixtureAuthToken = fixture?.auth_token;
if (gatewayId !== null && fixtureAuthToken) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
token: String(fixtureAuthToken),
}).catch(() => {});
}
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
' title="API Reference" id="API-Reference">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
"successful HTTP request handled by the broker container and defaults to the container's "
"start time when no request has been processed yet.</p>\n"
"</topic>\n"
)
+7
View File
@@ -146,6 +146,13 @@ tar \
-cf - \
Dockerfile \
Dockerfile.coolify-api \
docker-compose.yml \
docker-compose.example.yml \
docker-compose.prod.standalone.yml \
scripts/bird-control-plane-auto-activate.php \
scripts/bird-control-plane-bootstrap-local.sh \
scripts/xlvask-automation-migrate.php \
services/coolify/api/start.sh \
services/php/Dockerfile \
services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Pre-deploy schema bootstrap runner.
*
* Loads and runs every `*_schema_bootstrap` class so the production
* database has all the columns the current code expects. Each
* bootstrap is additive and idempotent — safe to run on every deploy.
*
* Run via:
* php scripts/run-schema-bootstraps.php
*
* Used in .github/workflows/deploy.yml as a pre-deploy step.
*
* When you add a new *_schema_bootstrap class, you don't need to
* edit this file — the runner auto-discovers any class whose name
* ends in `_schema_bootstrap`.
*/
namespace scripts;
// Load the app entry point so $db is wired up the same way as in
// normal request handling.
$index = __DIR__ . '/../services/nginx/app/index.php';
if (!file_exists($index)) {
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
exit(2);
}
require_once $index;
$classesDir = __DIR__ . '/../services/nginx/app/classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
if (!$bootstraps) {
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
exit(0);
}
$ran = 0;
$skipped = 0;
foreach ($bootstraps as $file) {
require_once $file;
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (!class_exists($class)) {
fwrite(STDERR, " [skip] {$base}: class not found\n");
$skipped++;
continue;
}
if (!method_exists($class, 'ensureSchema')) {
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
$skipped++;
continue;
}
try {
$class::ensureSchema();
echo " [ok] {$base}\n";
$ran++;
} catch (\Throwable $e) {
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
exit(1);
}
}
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
/**
* Schema health check — verifies all required DB columns exist.
*
* Run via:
* GET /api/admin/schema-check (returns JSON report)
* php scripts/schema-health-check.php (CLI, exits 0/1)
*
* Lists the columns that the code expects to find in each critical
* table. If a column is missing, the response is 503 (HTTP) or
* exit code 1 (CLI) — clearly distinct from a generic 500.
*
* Add to the list when introducing a new optional column.
*/
namespace scripts;
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
use classes\customer_invoice_email_schema_bootstrap;
const SCHEMA_REQUIREMENTS = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
function check_schema(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
// First: run the schema bootstrap (additive, idempotent) so we
// give the DB a chance to self-heal.
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
$report['tables_checked']++;
// Confirm the table itself exists
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
// CLI mode
if (PHP_SAPI === 'cli') {
$report = check_schema();
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
exit($report['ok'] ? 0 : 1);
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
+3 -3
View File
@@ -4,9 +4,9 @@
"requires": true,
"packages": {
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+37
View File
@@ -40,6 +40,10 @@ class Receiver extends Writable {
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
* buffered data chunks
* @param {Number} [options.maxFragments=0] The maximum number of message
* fragments
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
@@ -54,6 +58,8 @@ class Receiver extends Writable {
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxBufferedChunks = options.maxBufferedChunks | 0;
this._maxFragments = options.maxFragments | 0;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
@@ -71,6 +77,7 @@ class Receiver extends Writable {
this._totalPayloadLength = 0;
this._messageLength = 0;
this._numFragments = 0;
this._fragments = [];
this._errored = false;
@@ -89,6 +96,22 @@ class Receiver extends Writable {
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
if (
this._maxBufferedChunks > 0 &&
this._buffers.length >= this._maxBufferedChunks
) {
cb(
this.createError(
RangeError,
'Too many buffered chunks',
false,
1008,
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
)
);
return;
}
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
@@ -478,6 +501,19 @@ class Receiver extends Writable {
return;
}
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
const error = this.createError(
RangeError,
'Too many message fragments',
false,
1008,
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
);
cb(error);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
@@ -550,6 +586,7 @@ class Receiver extends Writable {
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._numFragments = 0;
this._fragments = [];
if (this._opcode === 2) {
+6 -1
View File
@@ -4,6 +4,9 @@
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const {
types: { isUint8Array }
} = require('util');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
@@ -200,8 +203,10 @@ class Sender {
if (typeof data === 'string') {
buf.write(data, 2);
} else {
} else if (isUint8Array(data)) {
buf.set(data, 2);
} else {
throw new TypeError('Second argument must be a string or a Uint8Array');
}
}
+8
View File
@@ -43,6 +43,10 @@ class WebSocketServer extends EventEmitter {
* called
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
* buffered data chunks
* @param {Number} [options.maxFragments=16384] The maximum number of message
* fragments
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
@@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter {
options = {
allowSynchronousEvents: true,
autoPong: true,
maxBufferedChunks: 256 * 1024,
maxFragments: 16 * 1024,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
@@ -424,6 +430,8 @@ class WebSocketServer extends EventEmitter {
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxBufferedChunks: this.options.maxBufferedChunks,
maxFragments: this.options.maxFragments,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
+14
View File
@@ -201,6 +201,10 @@ class WebSocket extends EventEmitter {
* multiple times in the same tick
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
* buffered data chunks
* @param {Number} [options.maxFragments=0] The maximum number of message
* fragments
* @param {Number} [options.maxPayload=0] The maximum allowed message size
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
@@ -212,6 +216,8 @@ class WebSocket extends EventEmitter {
binaryType: this.binaryType,
extensions: this._extensions,
isServer: this._isServer,
maxBufferedChunks: options.maxBufferedChunks,
maxFragments: options.maxFragments,
maxPayload: options.maxPayload,
skipUTF8Validation: options.skipUTF8Validation
});
@@ -640,6 +646,10 @@ module.exports = WebSocket;
* masking key
* @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
* handshake request
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
* buffered data chunks
* @param {Number} [options.maxFragments=16384] The maximum number of message
* fragments
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Number} [options.maxRedirects=10] The maximum number of redirects
@@ -660,6 +670,8 @@ function initAsClient(websocket, address, protocols, options) {
autoPong: true,
closeTimeout: CLOSE_TIMEOUT,
protocolVersion: protocolVersions[1],
maxBufferedChunks: 256 * 1024,
maxFragments: 16 * 1024,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: true,
@@ -1017,6 +1029,8 @@ function initAsClient(websocket, address, protocols, options) {
websocket.setSocket(socket, head, {
allowSynchronousEvents: opts.allowSynchronousEvents,
generateMask: opts.generateMask,
maxBufferedChunks: opts.maxBufferedChunks,
maxFragments: opts.maxFragments,
maxPayload: opts.maxPayload,
skipUTF8Validation: opts.skipUTF8Validation
});
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ws",
"version": "8.20.0",
"version": "8.21.1",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
@@ -66,5 +66,9 @@
"nyc": "^15.0.0",
"prettier": "^3.0.0",
"utf-8-validate": "^6.0.0"
},
"allowScripts": {
"bufferutil": true,
"utf-8-validate": true
}
}
+4 -4
View File
@@ -6,13 +6,13 @@
"": {
"name": "truckwash-edge-broker",
"dependencies": {
"ws": "^8.18.0"
"ws": "^8.21.1"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+1 -1
View File
@@ -7,6 +7,6 @@
"test:live": "node --test live/live-smoke.mjs"
},
"dependencies": {
"ws": "^8.18.0"
"ws": "^8.21.1"
}
}
+10 -2
View File
@@ -49,7 +49,7 @@ function resolveManagerUrl(options = {}) {
return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || "");
}
function resolveAuthMode(options = {}, managerUrl = "") {
function resolveAuthMode(options = {}) {
if (options.authMode) {
return options.authMode;
}
@@ -179,7 +179,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
export function createBrokerServer(options = {}) {
const sharedSecret = resolveSharedSecret(options);
const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl);
const authMode = resolveAuthMode(options);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
const browserStreamSessions = new Map();
const gatewayStreamSessions = new Map();
const inflightGatewaySyncs = new Map();
const containerStartedAt = currentTimestamp();
let lastActivityAt = containerStartedAt;
const managerRequest = async (path, body = {}, method = "POST") => {
if (!managerUrl) {
@@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
lastActivityAt = currentTimestamp();
if (req.method === "GET" && url.pathname === "/api/health") {
jsonResponse(res, 200, {
ok: true,
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size,
lastActivityAt,
});
return;
}
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
pendingCommands,
managerUrl,
authMode,
containerStartedAt,
get lastActivityAt() {
return lastActivityAt;
},
},
};
}
+39
View File
@@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => {
assert.equal(healthJson.auth_mode, "manager");
assert.equal(healthJson.manager_url_configured, true);
assert.equal(healthJson.shared_secret_configured, true);
assert.equal(typeof healthJson.lastActivityAt, "string");
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
await broker.close();
});
test("broker updates lastActivityAt after each successful request", async () => {
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
const address = await broker.listen(0);
const port = address.port;
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const firstJson = await firstResponse.json();
const firstActivityAt = broker.state.lastActivityAt;
assert.equal(typeof firstJson.lastActivityAt, "string");
assert.equal(firstJson.lastActivityAt, firstActivityAt);
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
await new Promise((resolve) => setTimeout(resolve, 5));
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
headers: {
"x-edge-broker-secret": "secret",
},
});
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
assert.ok(broker.state.lastActivityAt > firstActivityAt);
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const secondJson = await secondResponse.json();
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
await broker.close();
});
test("broker bridges browser shell sessions through the connected agent", async () => {
const closedSessions = [];
const broker = createBrokerServer({
+10 -10
View File
@@ -39,8 +39,8 @@ test("traefik does not expose a dedicated public edge broker port", () => {
test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
@@ -55,8 +55,8 @@ test("base docker compose routes edge broker traffic through traefik", () => {
test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
@@ -65,8 +65,8 @@ test("example docker compose routes edge broker traffic through traefik", () =>
test("standalone production compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-manager\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
@@ -78,8 +78,8 @@ test("standalone production compose routes edge broker traffic through traefik",
test("compose config does not provide insecure broker secret defaults", () => {
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
}
});
@@ -87,7 +87,7 @@ test("base docker compose wires the broker into each php worker", () => {
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
}
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
<?php
namespace classes;
use RuntimeException;
class account_deletion_http_exception extends RuntimeException
{
public function __construct(string $message, public readonly int $status)
{
parent::__construct($message);
}
}
@@ -0,0 +1,127 @@
<?php
namespace classes;
/**
* Explicit account-deletion schema management.
*
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
* are deliberately limited to the read-only check().
*/
class account_deletion_schema_bootstrap
{
/** @return array{ready:bool,missing:array<int,string>} */
public static function check(): array
{
global $db;
$missing = [];
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
$missing[] = 'table:' . $table;
}
}
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result === false || $result->num_rows === 0) {
$missing[] = 'column:' . $table . '.' . $column;
}
}
if (!in_array('table:account_deletion_requests', $missing, true)) {
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
if ($result === false || $result->num_rows === 0) {
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
}
}
return ['ready' => $missing === [], 'missing' => $missing];
}
public static function apply(): void
{
if (PHP_SAPI !== 'cli') {
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
}
global $db;
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id CHAR(36) NOT NULL,
principal_type VARCHAR(16) NOT NULL,
principal_id BIGINT UNSIGNED NOT NULL,
customer_number_snapshot INT NULL,
active_principal_key VARCHAR(191) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'requested',
policy_version VARCHAR(32) NOT NULL,
retained_data_json LONGTEXT NOT NULL,
request_ip VARCHAR(45) NULL,
request_user_agent VARCHAR(512) NULL,
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
failure_code VARCHAR(191) NULL,
requested_at DATETIME NOT NULL,
processing_at DATETIME NULL,
completed_at DATETIME NULL,
next_attempt_at DATETIME NULL,
manual_review_required_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_account_deletion_request_id (request_id),
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
throttle_key CHAR(64) NOT NULL,
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
window_started_at DATETIME NOT NULL,
blocked_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload_json LONGTEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
available_at DATETIME NOT NULL,
processing_at DATETIME NULL,
delivered_at DATETIME NULL,
last_error VARCHAR(191) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
INDEX idx_account_deletion_outbox_delivery (status, available_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
}
private static function execute(string $sql): void
{
global $db;
if ($db->query($sql) === false) {
throw new \RuntimeException('Account deletion schema operation failed.');
}
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
}
private static function ensureIndex(string $table, string $index, string $columns): void
{
global $db;
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
}
}
File diff suppressed because it is too large Load Diff
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
*/
public function isValidFilePath(string $filePath): bool
{
// Check if the file path is valid
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
if (
$filePath === ''
|| str_starts_with($filePath, '/')
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
) {
return false;
}
foreach (explode('/', $filePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
/**
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
{
$host = 'https://api.truckwash.io';
$this->requireValidFilePath($fileName);
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
// Generate a direct download URL for the given file name
return $host . '/files/' . $fileName;
return $host . '/files/' . $encodedPath;
}
}
}
+43 -4
View File
@@ -2,6 +2,8 @@
namespace classes;
require_once WD . '/classes/account_deletion_service.php';
use classes\totp;
use Exception;
use interfaces\authentication_i;
@@ -69,6 +71,10 @@ class authentication implements authentication_i
public function create_2fa_token(int $id, string $type): string
{
$principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer';
if (account_deletion_service::principalIsBlocked($principalType, $id)) {
throw new Exception('Account unavailable');
}
// Create a temporary 2FA token
$token = bin2hex(random_bytes(32));
(new tokens_o())->create($id, $token, $type);
@@ -100,6 +106,9 @@ class authentication implements authentication_i
throw new \Exception('User not found for customer number: ' . $customer_number);
}
$user_id = $user->id;
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
throw new Exception('Account unavailable');
}
// Save the token in the database
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
return $token;
@@ -107,6 +116,9 @@ class authentication implements authentication_i
public function create_token_by_user_id(int $user_id): string
{
if (account_deletion_service::principalIsBlocked('customer', $user_id)) {
throw new Exception('Account unavailable');
}
// Create a token
$token = bin2hex(random_bytes(32));
// Save the token in the database
@@ -116,6 +128,9 @@ class authentication implements authentication_i
public function create_employee_token(int $employee_id): string
{
if (account_deletion_service::principalIsBlocked('customer', $employee_id)) {
throw new Exception('Account unavailable');
}
// Create a token
$token = bin2hex(random_bytes(32));
// Save the token in the database
@@ -123,13 +138,26 @@ class authentication implements authentication_i
return $token;
}
public function create_impersonation_token(int $target_user_id, int $actor_user_id): string
{
if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) {
throw new Exception('Account unavailable');
}
$token = bin2hex(random_bytes(32));
(new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id);
return $token;
}
public function validate_token(string $token): bool
{
// First: try validating as a classic user auth token
try {
$dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
return true;
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
return !account_deletion_service::principalIsBlocked(
'customer',
(int)$dbToken->user_id->value()
);
}
} catch (Exception) {
// Ignore and continue to subuser session validation
@@ -137,7 +165,7 @@ class authentication implements authentication_i
// Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
if ($subuser !== null) {
return true;
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
}
return false;
}
@@ -168,7 +196,10 @@ class authentication implements authentication_i
if (!$token->id) {
return false;
}
if ($token->type->value() !== 'AUTH_TOKEN') {
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
return false;
}
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
return false;
}
// Get the user from the database
@@ -177,6 +208,11 @@ class authentication implements authentication_i
return $user;
}
private function isClassicAuthTokenType(string $type): bool
{
return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:');
}
public function get_plate_scanner(): plate_scanners_o|false
{
// Get the token from the headers
@@ -227,6 +263,9 @@ class authentication implements authentication_i
if ($subuser === null) {
return false;
}
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
return false;
}
$customerNumberContext = null;
if (isset($headers['X-Customer-Number'])) {
$customerNumberContext = (int)$headers['X-Customer-Number'];
@@ -0,0 +1,127 @@
<?php
namespace classes;
class backup_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS backup_records (
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
description TEXT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
status VARCHAR(32) NOT NULL DEFAULT 'queued',
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
storage_prefix VARCHAR(255) NOT NULL,
manifest_key VARCHAR(255) NULL,
manifest_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
component_count INT UNSIGNED NOT NULL DEFAULT 0,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
requested_by_user_id INT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
verified_at DATETIME NULL,
expires_at DATETIME NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_records_status_created (status, created_at),
KEY idx_backup_records_verified (verified_at),
KEY idx_backup_records_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_components (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
backup_uuid VARCHAR(64) NOT NULL,
component_type VARCHAR(32) NOT NULL,
logical_name VARCHAR(191) NOT NULL,
source_bucket VARCHAR(191) NULL,
source_prefix VARCHAR(255) NULL,
storage_key VARCHAR(255) NULL,
manifest_key VARCHAR(255) NULL,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
content_sha256 CHAR(64) NULL,
encrypted_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_components_backup (backup_uuid),
KEY idx_backup_components_status (status),
KEY idx_backup_components_type_name (component_type, logical_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(32) NOT NULL,
backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
actor_user_id INT NULL,
locked_at DATETIME NULL,
lock_owner VARCHAR(191) NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_jobs_status_created (status, created_at),
KEY idx_backup_jobs_backup (backup_uuid),
KEY idx_backup_jobs_type_status (job_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
restore_job_id BIGINT UNSIGNED NULL,
preview_job_id BIGINT UNSIGNED NULL,
backup_uuid VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
confirmation_fingerprint CHAR(64) NULL,
reason TEXT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
pre_restore_backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_restore_audit_backup (backup_uuid),
KEY idx_backup_restore_audit_job (restore_job_id),
KEY idx_backup_restore_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
-13
View File
@@ -212,19 +212,7 @@ class bird implements bird_i
throw new Exception('cURL error: ' . $err);
}
curl_close($ch);
// Debug slack
$data = json_decode($body, true) ?? [];
$resp = $resp === false ? 'cURL error with no response' : $resp;
$slack_debug_message = "*Bird API Request Debug:*"
. "\nEndpoint: $url"
. "\nMethod: $method"
. "\nStatus: $code"
. "\nPayload Keys: " . implode(',', array_keys($data))
. "\nResponse: $resp";
// Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
$slack = new \classes\slack();
$slack->send_message($slack_debug_message);
return [
'status_code' => (int)$code,
'body' => $resp,
@@ -1073,4 +1061,3 @@ class bird implements bird_i
}
}
@@ -64,6 +64,11 @@ class coolify_api_client
return $this->request('GET', '/services');
}
public function listApplications(): array
{
return $this->request('GET', '/applications');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
@@ -169,6 +174,11 @@ class coolify_api_client
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
}
public function stopService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
}
public function stopApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
@@ -179,6 +189,11 @@ class coolify_api_client
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function deleteApplication(string $uuid): array
{
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
+67 -7
View File
@@ -5,6 +5,8 @@ namespace classes;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/cors_policy.php';
class coolify_manager
{
private const KINDS = ['database', 'redis', 'minio'];
@@ -1070,7 +1072,9 @@ class coolify_manager
$targetPublicUrl,
$resourceUuid,
self::resourceFirstExposedPort($resource, $target),
$resource['custom_labels'] ?? null
$resource['custom_labels'] ?? null,
$app,
self::gatewayRouteTargetCorsConfig($target)
);
$update = $resourceType === 'service'
? $client->updateService($resourceUuid, $updatePayload)
@@ -2420,7 +2424,9 @@ class coolify_manager
string $publicUrl,
string $resourceUuid = '',
?int $port = null,
mixed $existingLabels = null
mixed $existingLabels = null,
string $app = '',
string $corsConfig = ''
): array
{
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
@@ -2435,7 +2441,9 @@ class coolify_manager
$publicUrl,
$resourceUuid,
$routePort,
self::gatewayRouteDefaultCertResolver($publicUrl)
self::gatewayRouteDefaultCertResolver($publicUrl),
$app,
$corsConfig
);
if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
@@ -2460,6 +2468,49 @@ class coolify_manager
];
}
private static function gatewayRouteTargetCorsConfig(array $target): string
{
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$configured = null;
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
$env = $context[$key] ?? null;
if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) {
$configured = (string)$env['CORS'];
}
}
foreach (['coolify_env_file', 'env'] as $key) {
$raw = $context[$key] ?? null;
if (!is_string($raw)) {
continue;
}
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
$line = trim((string)$line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$envKey, $value] = explode('=', $line, 2);
if (trim($envKey) === 'CORS') {
$configured = $value;
}
}
}
if ($configured === null) {
$runtimeValue = getenv('CORS');
if ($runtimeValue !== false) {
$configured = $runtimeValue;
} elseif (array_key_exists('CORS', $_ENV ?? [])) {
$configured = (string)$_ENV['CORS'];
} elseif (array_key_exists('CORS', $_SERVER ?? [])) {
$configured = (string)$_SERVER['CORS'];
}
}
return cors_policy::withRequiredOrigins((string)($configured ?? ''));
}
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
{
if ($port === null || $port <= 0) {
@@ -2483,7 +2534,9 @@ class coolify_manager
string $publicUrl,
string $resourceUuid,
?int $port = null,
?string $certResolver = null
?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array
{
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
@@ -2508,6 +2561,7 @@ class coolify_manager
$certResolver = trim((string)($certResolver ?? ''));
$httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid;
$isApi = strtolower(trim($app)) === 'api';
$labels = [
'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true',
@@ -2521,12 +2575,18 @@ class coolify_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
}
$httpsMiddlewares = [];
if ($isApi) {
$corsMiddleware = "{$httpsLabel}-cors";
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
$httpsMiddlewares[] = $corsMiddleware;
}
if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
}
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
+60 -5
View File
@@ -29,6 +29,7 @@ class cors_policy
'http://localhost:5174',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
'capacitor://localhost',
];
public static function normalizeOrigin(?string $value): string
@@ -38,7 +39,7 @@ class cors_policy
return $value;
}
if (preg_match('#^https?://#i', $value) !== 1) {
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
return '';
}
@@ -48,7 +49,7 @@ class cors_policy
}
$scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
return '';
}
@@ -58,6 +59,27 @@ class cors_policy
return $scheme . '://' . $host . $port;
}
public static function normalizeRequestOrigin(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || $value === '*') {
return '';
}
$parts = parse_url($value);
if (!is_array($parts)) {
return '';
}
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $disallowedPart) {
if (array_key_exists($disallowedPart, $parts)) {
return '';
}
}
return self::normalizeOrigin($value);
}
/**
* @return array<int,string>
*/
@@ -66,6 +88,39 @@ class cors_policy
return self::REQUIRED_ALLOWED_ORIGINS;
}
/**
* @return array<int,string>
*/
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
{
$middlewareName = trim($middlewareName);
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
return [];
}
$allowedHeaders = array_values(array_filter(
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
static fn(string $header): bool => $header !== '' && $header !== '*'
));
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
$allowedOrigins = self::allowedOrigins($corsConfig);
$originLabel = $allowedOrigins === ['*']
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
return [
"{$prefix}.accesscontrolallowcredentials=true",
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
$originLabel,
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
"{$prefix}.addvaryheader=true",
];
}
/**
* @return array<int,string>
*/
@@ -105,8 +160,8 @@ class cors_policy
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || $origin === '*') {
$origin = self::normalizeRequestOrigin($origin);
if ($origin === '') {
return false;
}
@@ -119,7 +174,7 @@ class cors_policy
*/
public static function responseHeaders(?string $origin, string $corsConfig): array
{
$origin = self::normalizeOrigin($origin);
$origin = self::normalizeRequestOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return [];
}
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,702 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if ($this->taskIsLocked($state)) {
throw new RuntimeException('Cron task is already running.');
}
$existing = $this->fetchOne(
"SELECT * FROM cron_task_runs
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
ORDER BY id DESC LIMIT 1"
);
if ($existing !== null) {
$this->markTaskQueued($definition);
return $this->publicRun($existing);
}
$scheduled_for = date('Y-m-d H:i:s');
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ", 'manual', 'queued', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->sql($scheduled_for) . ', '
. ($force ? '1' : '0')
. ")"
);
$run_id = (int)$this->insertId();
$this->markTaskQueued($definition);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
foreach ($this->queuedRuns() as $queuedRun) {
try {
$run = $this->runQueuedRun($queuedRun);
if ($run !== null) {
$ran[] = $run;
}
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => (string)($queuedRun['task_id'] ?? ''),
'module' => (string)($queuedRun['module'] ?? ''),
'source' => (string)($queuedRun['source'] ?? 'manual'),
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function markExpiredRunningRuns(): int
{
$this->ensureReady();
$now = date('Y-m-d H:i:s');
$message = 'Task lock expired before completion.';
$this->query(
"UPDATE cron_task_runs r
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
SET r.status = 'timed_out',
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
s.current_run_id = NULL,
s.locked_until = NULL,
s.lock_owner = NULL,
s.last_status = 'timed_out',
s.last_error = " . $this->sql($message) . "
WHERE r.status = 'running'
AND s.locked_until IS NOT NULL
AND s.locked_until < " . $this->sql($now)
);
return $this->affectedRows();
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
return $this->executeClaimedRun($definition, $run_id, $started, $scheduled_for);
}
private function executeClaimedRun(
cron_task_definition $definition,
int $run_id,
float $started,
?string $scheduled_for = null
): array
{
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at, $scheduled_for);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function taskIsLocked(array $state): bool
{
$lockedUntil = (string)($state['locked_until'] ?? '');
return $lockedUntil !== ''
&& strtotime($lockedUntil) !== false
&& strtotime($lockedUntil) >= time();
}
private function markTaskQueued(cron_task_definition $definition): void
{
$now = date('Y-m-d H:i:s');
$this->query(
"UPDATE cron_task_state
SET last_status = 'queued',
last_error = NULL,
next_run_at = CASE
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
ELSE next_run_at
END
WHERE task_id = " . $this->sql($definition->id)
);
}
/**
* @return array<int, array<string, mixed>>
*/
private function queuedRuns(): array
{
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
}
private function runQueuedRun(array $queuedRun): ?array
{
$run_id = (int)($queuedRun['id'] ?? 0);
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
if ($run_id < 1 || $definition === null) {
if ($run_id > 0) {
$this->skipQueuedRun($run_id, 'Cron task not found.');
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
return null;
}
$force = (bool)($queuedRun['force_run'] ?? false);
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
if (!$this->claimLock($definition)) {
return null;
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$this->query(
"UPDATE cron_task_runs
SET status = 'running',
started_at = " . $this->sql($started_at) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE id = $run_id AND status = 'queued'"
);
if ($this->affectedRows() !== 1) {
$this->clearClaimedLock($definition);
return null;
}
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
return $this->executeClaimedRun(
$definition,
$run_id,
$started,
isset($queuedRun['scheduled_for']) ? (string)$queuedRun['scheduled_for'] : null
);
}
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
{
$completed_at = date('Y-m-d H:i:s');
$this->query(
"UPDATE cron_task_runs
SET status = 'skipped',
completed_at = " . $this->sql($completed_at) . ",
duration_ms = 0,
error_message = " . $this->sql($message) . "
WHERE id = $run_id AND status = 'queued'"
);
$updated = $this->affectedRows() === 1;
if (!$updated || $definition === null) {
return;
}
$this->query(
"UPDATE cron_task_state
SET last_status = 'skipped',
last_error = " . $this->sql($message) . "
WHERE task_id = " . $this->sql($definition->id)
);
}
private function clearClaimedLock(cron_task_definition $definition): void
{
$this->query(
"UPDATE cron_task_state
SET locked_until = NULL,
lock_owner = NULL
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
}
private function releaseLock(
cron_task_definition $definition,
string $status,
?string $error_message,
string $completed_at,
?string $scheduled_for = null
): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
// Automatic runs stay anchored to their intended schedule slot. Anchoring
// to completion time causes every task to drift by its execution time.
$scheduleAnchor = $scheduled_for !== null && strtotime($scheduled_for) !== false
? $scheduled_for
: $completed_at;
$nextRunAt = cron_schedule::nextRunAt($schedule, $scheduleAnchor, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at,
bool $force = false
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner) . ', '
. ($force ? '1' : '0')
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function publicRun(array $run): array
{
if ($run === []) {
return [];
}
$run['force_run'] = (bool)($run['force_run'] ?? false);
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,121 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
force_run TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
$db->query(
"CREATE TABLE IF NOT EXISTS cron_worker_state (
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
hostname VARCHAR(191) NULL,
pid INT UNSIGNED NULL,
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
status VARCHAR(32) NOT NULL DEFAULT 'starting',
release_channel_id BIGINT UNSIGNED NULL,
release_target_id BIGINT UNSIGNED NULL,
coolify_resource_uuid VARCHAR(128) NULL,
coolify_resource_type VARCHAR(32) NULL,
commit_sha VARCHAR(64) NULL,
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
started_at DATETIME NULL,
last_heartbeat_at DATETIME NULL,
last_loop_started_at DATETIME NULL,
last_loop_finished_at DATETIME NULL,
last_loop_gap_seconds INT UNSIGNED NULL,
consecutive_minute_loops INT UNSIGNED NOT NULL DEFAULT 0,
stopped_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
KEY idx_cron_worker_state_status (status),
KEY idx_cron_worker_state_release_target (release_target_id),
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('cron_worker_state', 'last_loop_gap_seconds', 'INT UNSIGNED NULL AFTER last_loop_finished_at');
self::ensureColumn('cron_worker_state', 'consecutive_minute_loops', 'INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_loop_gap_seconds');
self::$initialized = true;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
+361
View File
@@ -0,0 +1,361 @@
<?php
namespace classes;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class cron_worker
{
use boolean_normalization_t;
private cron_scheduler $scheduler;
private string $worker_id;
private string $name;
private string $source;
private int $poll_seconds;
private int $heartbeat_seconds;
private int $max_runtime_seconds;
private bool $should_stop = false;
private int $last_heartbeat = 0;
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
{
$this->scheduler = $scheduler ?? new cron_scheduler();
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
}
public function run(): int
{
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
$this->heartbeat('disabled', 0, 0, null, true);
return 0;
}
$this->registerSignalHandlers();
$started = time();
$this->heartbeat('starting', 0, 0, null, true);
while (!$this->should_stop) {
$pollStarted = microtime(true);
$result = $this->tick();
$this->writeStatusLine($result);
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
$this->should_stop = true;
break;
}
$this->sleepUntilNextPoll($pollStarted + $this->poll_seconds);
}
$this->heartbeat('stopped', 0, 0, null, true, true);
return 0;
}
public function tick(): array
{
$this->heartbeat('running');
$loopStartedAt = date('Y-m-d H:i:s');
$staleRuns = 0;
$ran = ['count' => 0, 'ran' => []];
$error = null;
$status = 'running';
try {
$staleRuns = $this->scheduler->markExpiredRunningRuns();
$ran = $this->scheduler->runDue($this->source);
} catch (Throwable $throwable) {
$status = 'failed';
$error = $throwable->getMessage();
}
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
return [
'worker_id' => $this->worker_id,
'status' => $status,
'ran' => (int)($ran['count'] ?? 0),
'stale_runs' => $staleRuns,
'error' => $error,
];
}
public function listWorkers(): array
{
cron_schema_bootstrap::ensureTables();
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
$workers = [];
foreach ($rows as $row) {
$workers[] = $this->publicWorker($row);
}
return [
'workers' => $workers,
'summary' => [
'total' => count($workers),
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
],
];
}
private function registerSignalHandlers(): void
{
if (!function_exists('pcntl_signal')) {
return;
}
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
}
pcntl_signal(SIGTERM, function (): void {
$this->should_stop = true;
});
pcntl_signal(SIGINT, function (): void {
$this->should_stop = true;
});
}
private function sleepUntilNextPoll(float $nextPollAt): void
{
while (!$this->should_stop) {
$remaining = $nextPollAt - microtime(true);
if ($remaining <= 0) {
return;
}
usleep((int)(min(1.0, $remaining) * 1000000));
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
$this->heartbeat('running');
}
}
}
private function heartbeat(
string $status,
int $runCount = 0,
int $staleRunCount = 0,
?string $error = null,
bool $force = false,
bool $stopped = false,
?string $loopStartedAt = null
): void {
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
return;
}
cron_schema_bootstrap::ensureTables();
$this->last_heartbeat = time();
$now = date('Y-m-d H:i:s');
$workerId = $this->sql($this->worker_id);
$name = $this->sql($this->name);
$hostname = $this->nullableSql(gethostname() ?: null);
$pid = getmypid() ?: 0;
$source = $this->sql($this->source);
$statusSql = $this->sql($status);
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
$commitSha = $this->nullableSql($this->commitSha());
$errorSql = $this->nullableSql($error);
$loopStarted = $this->nullableSql($loopStartedAt);
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
$nowSql = $this->sql($now);
$this->query(
"INSERT INTO cron_worker_state (
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
last_loop_finished_at, last_loop_gap_seconds, consecutive_minute_loops, stopped_at
) VALUES (
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
$staleRunCount, $errorSql, $nowSql, $nowSql, $loopStarted,
$nowSql, NULL, " . ($loopStartedAt !== null ? '1' : '0') . ", $stoppedAt
)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
hostname = VALUES(hostname),
pid = VALUES(pid),
source = VALUES(source),
status = VALUES(status),
release_channel_id = VALUES(release_channel_id),
release_target_id = VALUES(release_target_id),
coolify_resource_uuid = VALUES(coolify_resource_uuid),
coolify_resource_type = VALUES(coolify_resource_type),
commit_sha = VALUES(commit_sha),
poll_seconds = VALUES(poll_seconds),
last_run_count = VALUES(last_run_count),
last_stale_run_count = VALUES(last_stale_run_count),
last_error = VALUES(last_error),
last_heartbeat_at = VALUES(last_heartbeat_at),
last_loop_gap_seconds = CASE
WHEN VALUES(last_loop_started_at) IS NULL OR last_loop_started_at IS NULL THEN last_loop_gap_seconds
ELSE GREATEST(0, TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)))
END,
consecutive_minute_loops = CASE
WHEN VALUES(last_loop_started_at) IS NULL THEN consecutive_minute_loops
WHEN last_loop_started_at IS NULL THEN 1
WHEN TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)) BETWEEN 0 AND 60
THEN consecutive_minute_loops + 1
ELSE 1
END,
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
last_loop_finished_at = VALUES(last_loop_finished_at),
stopped_at = VALUES(stopped_at)"
);
}
private function publicWorker(array $row): array
{
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
$heartbeatTs = strtotime($heartbeatAt);
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
$loopStartedAt = (string)($row['last_loop_started_at'] ?? '');
$loopStartedTs = strtotime($loopStartedAt);
$loopAge = $loopStartedTs !== false ? max(0, time() - $loopStartedTs) : null;
$loopGap = isset($row['last_loop_gap_seconds']) ? (int)$row['last_loop_gap_seconds'] : null;
$consecutiveMinuteLoops = (int)($row['consecutive_minute_loops'] ?? 0);
$minuteCadenceVerified = ($row['status'] ?? '') === 'running'
&& $loopAge !== null
&& $loopAge <= 60
&& $loopGap !== null
&& $loopGap <= 60
&& $consecutiveMinuteLoops >= 2;
return [
'worker_id' => (string)($row['worker_id'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'hostname' => $row['hostname'] ?? null,
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
'source' => (string)($row['source'] ?? ''),
'status' => (string)($row['status'] ?? 'unknown'),
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
'commit_sha' => $row['commit_sha'] ?? null,
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
'last_run_count' => (int)($row['last_run_count'] ?? 0),
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
'last_error' => $row['last_error'] ?? null,
'started_at' => $row['started_at'] ?? null,
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
'last_heartbeat_age_seconds' => $age,
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
'last_loop_age_seconds' => $loopAge,
'last_loop_gap_seconds' => $loopGap,
'consecutive_minute_loops' => $consecutiveMinuteLoops,
'minute_cadence' => [
'verified' => $minuteCadenceVerified,
'maximum_gap_seconds' => 60,
'last_gap_seconds' => $loopGap,
'consecutive_loops' => $consecutiveMinuteLoops,
],
'stopped_at' => $row['stopped_at'] ?? null,
'stale' => $age === null || $age > $threshold,
'stale_after_seconds' => $threshold,
];
}
private function writeStatusLine(array $result): void
{
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. PHP_EOL;
}
private function stringOption(array $options, string $key, string $env, string $default): string
{
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
return $value !== '' ? $value : $default;
}
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
{
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
return max($min, min($max, $value));
}
private function boolOption(string $env, bool $default): bool
{
$value = $this->env($env);
if ($value === null || trim($value) === '') {
return $default;
}
return self::normalizeBoolean($value);
}
private function commitSha(): string
{
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
$value = trim((string)($this->env($key) ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function env(string $key): ?string
{
$value = getenv($key);
if ($value !== false) {
return (string)$value;
}
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
}
private function nullableInt(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
return 'NULL';
}
return (string)max(0, (int)$value);
}
private function nullableSql(?string $value): string
{
$value = $value !== null ? trim($value) : '';
return $value === '' ? 'NULL' : $this->sql($value);
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
}
@@ -0,0 +1,93 @@
<?php
namespace classes;
/**
* Ensures additive schema for the customer `invoice_email` field
* (TRU-77 / DRIFT 16). The field is optional and stores an
* e-mail address that should receive the customer's invoices
* separately from the customer's primary `email`.
*/
class customer_invoice_email_schema_bootstrap
{
private static bool $initialized = false;
private const TABLE = 'users';
private const COLUMN = 'invoice_email';
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::ensureUsersTable($db);
self::ensureInvoiceEmailColumn($db);
self::$initialized = true;
}
private static function ensureUsersTable(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
display_name VARCHAR(255) NULL,
email VARCHAR(255) NULL,
phone_country_code INT NULL,
phone BIGINT NULL,
password VARCHAR(255) NULL,
group_id INT NOT NULL DEFAULT 0,
xlvask_customer_id VARCHAR(255) NULL,
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
wash_certificate_email VARCHAR(255) NULL,
invoice_email VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(255) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
KEY idx_users_customer_number (customer_number),
KEY idx_users_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
private static function ensureInvoiceEmailColumn(object $db): void
{
if (!self::tableExists($db, self::TABLE)) {
return;
}
if (self::columnExists($db, self::TABLE, self::COLUMN)) {
return;
}
$safeTable = str_replace('`', '', self::TABLE);
$db->query(
"ALTER TABLE `{$safeTable}`
ADD COLUMN " . self::COLUMN . " VARCHAR(255) NULL
AFTER wash_certificate_email"
);
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
@@ -14,6 +14,10 @@ class customer_mass_import_service
*/
public function import(array $payload): array
{
// TRU-77 / DRIFT 16: ensure the invoice_email column exists before we
// attempt to populate it on a local customer.
customer_invoice_email_schema_bootstrap::ensureSchema();
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
@@ -62,9 +66,15 @@ class customer_mass_import_service
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
// TRU-77 / DRIFT 16: resolve the e-conomic delivery address into a
// local variable instead of overwriting $normalized['email']. The
// primary customer email must remain intact for the result payload
// and for downstream local-customer sync; the create call needs the
// dedicated invoice address (or the primary as a fallback) on its
// own.
$createEmail = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
@@ -111,6 +121,7 @@ class customer_mass_import_service
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
@@ -137,6 +148,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
@@ -189,6 +204,42 @@ class customer_mass_import_service
return $email;
}
/**
* Normalize the optional dedicated invoice email (TRU-77 / DRIFT 16).
* Empty/whitespace values collapse to null. An explicit non-empty value
* must be a syntactically valid email address; an invalid value is
* rejected to keep invoices from being routed to a malformed address.
*/
protected function normalizeInvoiceEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid invoice email address.', 400);
}
return $email;
}
/**
* Resolve the e-mail address that e-conomic should use to deliver
* invoices for the customer (TRU-77 / DRIFT 16). Prefers the dedicated
* `invoice_email` when provided, falling back to the customer's primary
* `email`.
*/
protected function resolveInvoiceEmail(array $normalized, array &$warnings): string
{
if (!empty($normalized['invoice_email'])) {
return (string)$normalized['invoice_email'];
}
if (!empty($normalized['email'])) {
return (string)$normalized['email'];
}
$warnings[] = 'No invoice email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
@@ -205,12 +256,9 @@ class customer_mass_import_service
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
// TRU-77 / DRIFT 16: invoices must be routed to the dedicated
// invoice_email when provided, otherwise to the customer's email.
return $this->resolveInvoiceEmail($normalized, $warnings);
}
protected function searchEconomicCustomersByCvr(string $cvr): array
@@ -225,7 +273,7 @@ class customer_mass_import_service
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
@@ -237,7 +285,10 @@ class customer_mass_import_service
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
// TRU-77 / DRIFT 16: the dedicated invoice_email (or the
// primary email as a fallback) is passed in explicitly so the
// caller's $normalized['email'] is never mutated here.
'email' => $createEmail,
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
@@ -392,6 +443,7 @@ class customer_mass_import_service
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'invoice_email' => $normalized['invoice_email'] ?? null,
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
@@ -412,6 +464,7 @@ class customer_mass_import_service
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$invoice_email = $normalized['invoice_email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
@@ -427,6 +480,16 @@ class customer_mass_import_service
}
}
// TRU-77 / DRIFT 16: persist the dedicated invoice email override
// when provided so invoice routing survives subsequent local edits.
if ($invoice_email !== null && $customer->getInvoiceEmailOverride() === null) {
try {
$customer->setInvoiceEmail($invoice_email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local invoice email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
@@ -0,0 +1,46 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
if ($message !== null) {
throw new RuntimeException($message);
}
}
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$customerNumber = self::loadOrderCustomerNumber($orderId);
if ($customerNumber === null) {
return null;
}
$violation = (new customer_rule_product_restriction_service())
->violationForCustomerProduct($customerNumber, $productId);
return $violation === null ? null : (string)$violation['message'];
}
private static function loadOrderCustomerNumber(int $orderId): ?int
{
global $db;
if ($orderId < 1) {
return null;
}
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$customerNumber = (int)($row['customer_id'] ?? 0);
return $customerNumber > 0 ? $customerNumber : null;
}
}
@@ -3,18 +3,13 @@
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
/**
* @return array{rule:string,message:string}|null
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
@@ -22,137 +17,17 @@ class customer_product_rule_service
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
(int)$order->customer_id->value(),
$productId
);
if (!$result) {
return 0;
if ($violation === null) {
return null;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
// Keep the singular key during the API migration for existing invoice
// and logging consumers while also returning every matching rule.
return ['rule' => (string)$violation['rules'][0]] + $violation;
}
}
@@ -0,0 +1,291 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
/**
* Additive schema and the one-time legacy-to-exact-product migration for
* customer-rule product restrictions.
*/
class customer_rule_product_restriction_schema_bootstrap
{
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
private static bool $initialized = false;
/** @var array<string, string> */
private const RULES = [
'restrictAdditionalServices' => 'Additional services',
'restrictTankCleaning' => 'Tank cleaning',
'restrictSpotFree' => 'SpotFree',
'restrictInteriorCleaning' => 'Interior cleaning',
'onlyTankCleaning' => 'Non-tank products',
];
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::createTables($db);
self::deduplicateCustomerAttributes($db);
self::seedLegacyProductSets($db);
self::$initialized = true;
}
private static function createTables(object $db): void
{
$statements = [
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
attribute VARCHAR(191) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (attribute)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
attribute VARCHAR(191) NOT NULL,
name VARCHAR(191) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
CONSTRAINT fk_customer_rule_collection_attribute
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
collection_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id, product_id),
KEY idx_customer_rule_collection_product (product_id, collection_id),
CONSTRAINT fk_customer_rule_collection_product_collection
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
migration_key VARCHAR(191) NOT NULL,
details_json LONGTEXT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (migration_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
actor_user_id INT UNSIGNED NULL,
attribute VARCHAR(191) NOT NULL,
old_version INT UNSIGNED NOT NULL,
new_version INT UNSIGNED NOT NULL,
changes_json LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($statements as $statement) {
if ($db->query($statement) === false) {
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
}
}
}
private static function deduplicateCustomerAttributes(object $db): void
{
if (!self::tableExists($db, 'customer_attributes')) {
return;
}
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
return;
}
if ($db->query(
'DELETE duplicate_row FROM customer_attributes duplicate_row
INNER JOIN customer_attributes keep_row
ON keep_row.user_id = duplicate_row.user_id
AND keep_row.attribute = duplicate_row.attribute
AND keep_row.id < duplicate_row.id'
) === false) {
throw new RuntimeException('Unable to deduplicate customer attributes');
}
if ($db->query(
'ALTER TABLE customer_attributes
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
) === false) {
throw new RuntimeException('Unable to enforce unique customer attributes');
}
}
private static function seedLegacyProductSets(object $db): void
{
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
return;
}
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
$existing = $db->query(
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
);
if ($existing && (int)$existing->num_rows > 0) {
return;
}
if ($db->query('START TRANSACTION') === false) {
throw new RuntimeException('Unable to start customer-rule product migration');
}
try {
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
) === false) {
throw new RuntimeException('Unable to claim customer-rule product migration');
}
if (self::affectedRows($db) === 0) {
$db->query('ROLLBACK');
return;
}
foreach (array_keys(self::RULES) as $attribute) {
$safeAttribute = self::escape($db, $attribute);
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
VALUES ('{$safeAttribute}', 1)"
) === false) {
throw new RuntimeException("Unable to initialize restriction {$attribute}");
}
}
$counts = [];
$seededProductIds = [];
foreach (self::RULES as $attribute => $collectionName) {
$safeAttribute = self::escape($db, $attribute);
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
) === false) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$collectionId = (int)$db->insert_id();
if ($collectionId < 1) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$predicate = self::legacyPredicate($db, $attribute);
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
? 'p.deleted_at IS NULL'
: '1 = 1';
$insert = $db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
SELECT {$collectionId}, p.id
FROM products p
LEFT JOIN categories c ON c.id = p.category
WHERE ({$activePredicate}) AND ({$predicate})"
);
if ($insert === false) {
throw new RuntimeException("Unable to seed products for {$attribute}");
}
$counts[$attribute] = self::affectedRows($db);
$seeded = $db->query(
"SELECT product_id FROM customer_rule_product_collection_products
WHERE collection_id = {$collectionId} ORDER BY product_id"
);
$seededProductIds[$attribute] = [];
if ($seeded) {
while ($row = $seeded->fetch_assoc()) {
$seededProductIds[$attribute][] = (int)$row['product_id'];
}
}
}
$details = self::escape($db, (string)json_encode([
'counts' => $counts,
'product_ids' => $seededProductIds,
'seeded_at' => gmdate(DATE_ATOM),
], JSON_UNESCAPED_SLASHES));
if ($db->query(
"UPDATE customer_rule_product_migrations
SET details_json = '{$details}', applied_at = NOW()
WHERE migration_key = '{$migrationKey}'"
) === false) {
throw new RuntimeException('Unable to record customer-rule product migration');
}
if ($db->query('COMMIT') === false) {
throw new RuntimeException('Unable to commit customer-rule product migration');
}
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
private static function legacyPredicate(object $db, string $attribute): string
{
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
return match ($attribute) {
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
: ''),
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
default => '0 = 1',
};
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = self::escape($db, $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function indexExists(object $db, string $table, string $index): bool
{
$safeTable = str_replace('`', '', $table);
$safeIndex = self::escape($db, $index);
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = self::escape($db, $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
private static function escape(object $db, string $value): string
{
return method_exists($db, 'escape_string')
? $db->escape_string($value)
: addslashes($value);
}
private static function affectedRows(object $db): int
{
if (method_exists($db, 'conn')) {
$connection = $db->conn();
return (int)($connection->affected_rows ?? 0);
}
return (int)($db->affected_rows ?? 0);
}
}
@@ -0,0 +1,508 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class customer_rule_product_restriction_exception extends RuntimeException
{
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
{
parent::__construct($message);
$this->restrictionCode = $code;
}
private string $restrictionCode;
public function httpStatus(): int
{
return $this->httpStatus;
}
public function restrictionCode(): string
{
return $this->restrictionCode;
}
}
/**
* Source of truth for globally configured customer-rule product collections.
*/
class customer_rule_product_restriction_service
{
/** @var list<string> */
public const PRODUCT_IMPACT_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
];
/** @var list<string> */
public const SUPPORTED_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
'requiresReferenceNumber',
'requiresRegistrationNumbersInvoice',
'invoiceAllOrdersIndividually',
'invoiceWithStripe',
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
];
public function __construct()
{
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
}
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
public function listConfiguration(): array
{
return [
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
'products' => $this->productCatalog(),
];
}
/** @return array<string,mixed> */
public function ruleConfiguration(string $attribute): array
{
$this->assertSupportedAttribute($attribute);
global $db;
$safeAttribute = $this->escape($attribute);
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
}
$versionRow = $versionResult->fetch_assoc();
$result = $db->query(
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
FROM customer_rule_product_collections c
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE c.attribute = '{$safeAttribute}'
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
);
if (!$result) {
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
}
$collections = [];
$disabled = [];
while ($row = $result->fetch_assoc()) {
$collectionId = (int)$row['collection_id'];
if (!isset($collections[$collectionId])) {
$collections[$collectionId] = [
'id' => $collectionId,
'name' => (string)$row['name'],
'sort_order' => (int)$row['sort_order'],
'product_ids' => [],
];
}
if ($row['product_id'] !== null) {
$productId = (int)$row['product_id'];
$collections[$collectionId]['product_ids'][] = $productId;
$disabled[$productId] = true;
}
}
return [
'attribute' => $attribute,
'version' => max(1, (int)($versionRow['version'] ?? 1)),
'collections' => array_values($collections),
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
{
$this->assertSupportedAttribute($attribute);
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
global $db;
$safeAttribute = $this->escape($attribute);
if ($db->query('START TRANSACTION') === false) {
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
try {
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
}
$versionRow = $versionResult->fetch_assoc();
$currentVersion = (int)$versionRow['version'];
if ($currentVersion !== $expectedVersion) {
throw new customer_rule_product_restriction_exception(
'Customer rule configuration has changed; reload before saving',
409,
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
);
}
$old = $this->ruleConfiguration($attribute);
$existingIds = $this->existingCollectionIds($attribute);
foreach ($collections as $collection) {
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
}
}
// Avoid temporary unique-name collisions while two collections swap names.
foreach ($existingIds as $collectionId => $_) {
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$temporaryName}'
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$keptIds = [];
foreach ($collections as $collection) {
$name = $this->escape($collection['name']);
$sortOrder = (int)$collection['sort_order'];
$collectionId = $collection['id'];
if ($collectionId === null) {
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to create collection');
}
$collectionId = (int)$db->insert_id();
} else {
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$name}', sort_order = {$sortOrder}
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update collection');
}
}
$keptIds[$collectionId] = true;
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
foreach ($collection['product_ids'] as $productId) {
if ($db->query(
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to save collection products');
}
}
}
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
if ($removeIds !== []) {
if ($db->query(
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
implode(',', array_map('intval', $removeIds)) . ')'
) === false) {
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$newVersion = $currentVersion + 1;
if ($db->query(
"UPDATE customer_rule_product_restrictions
SET version = {$newVersion}, updated_at = NOW()
WHERE attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
$new = $this->ruleConfiguration($attribute);
$changes = $this->escape((string)json_encode([
'before' => $old,
'after' => $new,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if ($db->query(
"INSERT INTO customer_rule_product_audit_logs
(actor_user_id, attribute, old_version, new_version, changes_json)
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
if ($db->query('COMMIT') === false) {
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
return $new;
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
/**
* Return configured product restrictions for all active product-impact
* attributes belonging to any account with the customer number.
*
* @return list<array<string,mixed>>
*/
public function restrictionsForCustomerNumber(int $customerNumber): array
{
if ($customerNumber < 1) {
return [];
}
global $db;
$result = $db->query(
"SELECT DISTINCT ca.attribute
FROM users u
INNER JOIN customer_attributes ca ON ca.user_id = u.id
WHERE u.customer_number = {$customerNumber}"
);
if (!$result) {
throw new RuntimeException('Unable to load active customer-rule product restrictions');
}
$activeAttributes = [];
while ($row = $result->fetch_assoc()) {
$attribute = (string)$row['attribute'];
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$activeAttributes[$attribute] = true;
}
}
$active = [];
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
if (isset($activeAttributes[$attribute])) {
$active[] = $this->ruleConfiguration($attribute);
}
}
return $active;
}
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
{
if ($productId < 1) {
return null;
}
$rules = [];
$collections = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
continue;
}
$rules[] = (string)$restriction['attribute'];
foreach ($restriction['collections'] as $collection) {
if (in_array($productId, $collection['product_ids'], true)) {
$collections[] = (int)$collection['id'];
}
}
}
if ($rules === []) {
return null;
}
return [
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
'message' => customer_product_rule_service::BLOCK_MESSAGE,
'product_id' => $productId,
'rules' => array_values(array_unique($rules)),
'collections' => array_values(array_unique($collections)),
];
}
/**
* @param list<array<string,mixed>> $attributes
* @return list<array<string,mixed>>
*/
public function enrichAttributes(int $customerNumber, array $attributes): array
{
$restrictions = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
$restrictions[(string)$restriction['attribute']] = [
'attribute' => (string)$restriction['attribute'],
'version' => (int)$restriction['version'],
'collections' => $restriction['collections'],
'disabled_product_ids' => $restriction['disabled_product_ids'],
];
}
foreach ($attributes as &$attribute) {
$key = (string)($attribute['attribute'] ?? '');
$attribute['product_restriction'] = $restrictions[$key] ?? null;
}
unset($attribute);
return $attributes;
}
/** @return list<array<string,mixed>> */
private function productCatalog(): array
{
global $db;
$activeExpression = $this->columnExists('products', 'deleted_at')
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
: '1';
$result = $db->query(
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
{$activeExpression} AS active
FROM products p
LEFT JOIN categories c ON c.id = p.category
ORDER BY c.name ASC, p.name ASC, p.id ASC"
);
if (!$result) {
throw new RuntimeException('Unable to load the customer-rule product catalog');
}
$products = [];
while ($row = $result->fetch_assoc()) {
$products[] = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'category_id' => (int)$row['category_id'],
'category_name' => (string)($row['category_name'] ?? ''),
'active' => (bool)$row['active'],
];
}
return $products;
}
/**
* @return array<int,true>
*/
private function existingCollectionIds(string $attribute): array
{
global $db;
$safeAttribute = $this->escape($attribute);
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
if (!$result) {
throw new RuntimeException("Unable to load existing collections for {$attribute}");
}
$ids = [];
while ($row = $result->fetch_assoc()) {
$ids[(int)$row['id']] = true;
}
return $ids;
}
/** @return list<array{id: ?int, name: string, sort_order: int, product_ids: list<int>}> */
private function validateCollections(string $attribute, mixed $value): array
{
if (!is_array($value)) {
throw new customer_rule_product_restriction_exception('collections must be an array');
}
$normalized = [];
$names = [];
$collectionIds = [];
$allProductIds = [];
foreach (array_values($value) as $index => $collection) {
if (!is_array($collection)) {
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
}
$name = trim((string)($collection['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 191) {
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
}
$nameKey = mb_strtolower($name);
if (isset($names[$nameKey])) {
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
}
$names[$nameKey] = true;
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
throw new customer_rule_product_restriction_exception('product_ids must be an array');
}
$productIds = [];
foreach ($collection['product_ids'] as $productId) {
$id = $this->positiveInt($productId, 'product_id');
$productIds[$id] = true;
$allProductIds[$id] = true;
}
$id = isset($collection['id']) && $collection['id'] !== null
? $this->positiveInt($collection['id'], 'collection id')
: null;
if ($id !== null && isset($collectionIds[$id])) {
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
}
if ($id !== null) {
$collectionIds[$id] = true;
}
$normalized[] = [
'id' => $id,
'name' => $name,
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
? (int)$collection['sort_order']
: $index,
'product_ids' => array_map('intval', array_keys($productIds)),
];
}
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
return $normalized;
}
/** @param list<int> $productIds */
private function assertProductsExist(array $productIds): void
{
if ($productIds === []) {
return;
}
global $db;
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
if (!$result) {
throw new customer_rule_product_restriction_exception(
'Unable to validate collection products',
500,
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
);
}
$found = [];
while ($row = $result->fetch_assoc()) {
$found[(int)$row['id']] = true;
}
$missing = array_values(array_diff($productIds, array_keys($found)));
if ($missing !== []) {
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
}
}
private function assertSupportedAttribute(string $attribute): void
{
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
}
}
private function positiveInt(mixed $value, string $field): int
{
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
}
return (int)$value;
}
private function escape(string $value): string
{
global $db;
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
}
private function columnExists(string $table, string $column): bool
{
global $db;
$safeTable = str_replace('`', '', $table);
$safeColumn = $this->escape($column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
+42 -13
View File
@@ -177,15 +177,19 @@ class db
return $this->database;
}
public function getPort(): int
{
return $this->port;
}
public function getSslMode(): string
{
return $this->ssl_mode;
}
public function backupDatabase(string $path): bool
{
// Save the database to the path
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
$mode = strtoupper(trim($this->ssl_mode));
// Map ssl_mode to MariaDB client flags
// DISABLED => --skip-ssl (no TLS)
// PREFERRED => (no flag; client decides)
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
$sslFlag = '';
switch ($mode) {
case 'DISABLED':
@@ -201,17 +205,42 @@ class db
$sslFlag = '--ssl';
break;
}
$host = escapeshellarg($this->host);
$user = escapeshellarg($this->user);
$pass = escapeshellarg($this->password);
$db = escapeshellarg($this->database);
$port = (int)$this->port;
$outfile = escapeshellarg($path);
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
exec($command, $output, $return);
// Check if the command was successful
return $return === 0;
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
return false;
}
$environment = array_merge(getenv() ?: [], $_ENV);
$environment['MYSQL_PWD'] = $this->password;
$descriptors = [
0 => ['pipe', 'r'],
1 => ['file', $path, 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
return false;
}
fclose($pipes[0]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$return = proc_close($process);
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
@file_put_contents($path . '.error.log', $stderr);
}
return $return === 0 && is_file($path) && filesize($path) !== false;
}
public function getView(string $view): array
@@ -238,4 +267,4 @@ class db
}
}
}
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`user_id` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
`product_or_category_id` VARCHAR(191) NOT NULL,
`percentage` INT NOT NULL DEFAULT 0,
`fixed_price` INT NULL DEFAULT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,458 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
'revision' => $this->revision($overrides),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
$existingOverrides = [];
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$mysqli = $db->conn();
$mysqli->begin_transaction();
try {
$this->lockDepartment($departmentId);
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$currentRevision = $this->revision($existingOverrides);
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
throw $this->revisionConflict($currentRevision);
}
$deleteStatement = $mysqli->prepare(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?'
);
$insertStatement = $mysqli->prepare(
'INSERT INTO `department_customer_price_overrides`
(`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($deleteStatement === false || $insertStatement === false) {
throw new \RuntimeException('Unable to prepare department customer pricing update.');
}
$customerId = (int)$customer['id'];
$deleteStatement->bind_param('ii', $departmentId, $customerId);
if (!$deleteStatement->execute()) {
throw new \RuntimeException('Unable to clear department customer pricing.');
}
foreach ($normalized as $override) {
$isCategory = (int)(bool)$override['is_category'];
$objectId = (string)$override['product_or_category_id'];
$percentage = (int)$override['percentage'];
$fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price'];
$insertStatement->bind_param(
'iiisii',
$departmentId,
$customerId,
$isCategory,
$objectId,
$percentage,
$fixedPrice
);
if (!$insertStatement->execute()) {
throw new \RuntimeException('Unable to save department customer pricing.');
}
}
$deleteStatement->close();
$insertStatement->close();
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
if ($fixedPrice !== null) {
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
}
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
if (isset($normalized[$key])) {
throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400);
}
if ($fixedPrice !== null && $percentage > 0) {
throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400);
}
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
/**
* @param array<int, array<string, mixed>> $overrides
*/
private function revision(array $overrides): string
{
$revisionRows = array_map(static fn(array $override): array => [
'is_category' => (bool)$override['is_category'],
'product_or_category_id' => (string)$override['product_or_category_id'],
'percentage' => (int)$override['percentage'],
'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'],
], $overrides);
usort($revisionRows, static function (array $left, array $right): int {
return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']]
<=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']];
});
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
}
private function normalizeExpectedRevision(mixed $value): ?string
{
if ($value === null || $value === '') {
// Keep the backend-first rollout compatible with the currently deployed UI.
return null;
}
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
'message' => 'Expected revision is invalid.',
'code' => 'pricing_revision_invalid',
]);
}
return $value;
}
private function lockDepartment(int $departmentId): void
{
global $db;
$result = $db->query(
'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Department not found', 404);
}
}
private function revisionConflict(string $currentRevision): limited_backoffice_exception
{
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
'message' => 'Pricing has changed. Reload and try again.',
'code' => 'pricing_revision_conflict',
'current_revision' => $currentRevision,
]);
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -302,10 +302,10 @@ class department_outside_hours_statistics_service
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<string,array<int,bool>>|null $missing_lookup_by_day
* @return array{
* counted:bool,
* reason:string,
* candidate_date:?string,
* department_id:int
* counted: bool,
* reason: string,
* candidate_date: ?string,
* department_id: int
* }
*/
public function classifyCandidateAgainstOpeningHours(
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
COALESCE(SUM(oi.quantity), 0) AS products,
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL";
$result = $db->query($sql);
$row = is_object($result) ? $result->fetch_assoc() : null;
return [
'quantity' => (int)($row['quantity'] ?? 0),
'products' => (int)($row['products'] ?? 0),
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY created_at ASC";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'department_id' => (int)($row['department_id'] ?? 0),
'created_at' => (string)($row['created_at'] ?? ''),
];
}
return $rows;
}
/**
* @param array<int> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND p.is_wash = 1
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
}
}
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
object|array|null $company_information = null,
?string $ean = null
): object
{
$payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload);
}
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
}
/**
@@ -322,23 +328,21 @@ class economic_transfer_executor
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$order_item_price = (float)($order_item['price'] ?? 0);
$product_price = (float)($order_item['product']['price'] ?? 0);
$discount_percentage = 0.0;
if (abs($product_price) > 0.00001 && $order_item_price < $product_price) {
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 10);
}
$economic_invoice_draft->addLine(
$product_number,
$product_name,
$quantity,
$order_item_price,
0,
$discount_percentage,
(int)$economic_department_id ?? 0,
(int)$economic_dimension_id ?? 0
);
$show_discount = abs($order_item_price - $product_price) > 0.00001;
if ($show_discount && abs($product_price) > 0.00001) {
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0);
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)');
}
if ($reference !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($reference, "\n")) {
@@ -40,9 +40,14 @@ class economic_transfer_queue
$max_attempts = max(1, min(10, $max_attempts));
$transfer_type = $this->validateTransferType($transfer_type);
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
$collected_invoice_lock = $this->acquireCollectedInvoiceExportLock($transfer_type, $payload);
if ($collected_invoice_lock !== null) {
$this->assertCollectedInvoiceExportIsStillEligible($payload);
}
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
if ($active_job !== null) {
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
$target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent(
1,
@@ -75,6 +80,8 @@ class economic_transfer_queue
$job_id = (int)$db->insert_id();
$stmt->close();
$this->registerJobRequester($job_id, $created_by);
$this->logQueueEvent(
1,
$created_by,
@@ -145,12 +152,19 @@ class economic_transfer_queue
return null;
}
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
$stmt = $db->prepare(
"SELECT q.*
FROM economic_transfer_queue_jobs q
LEFT JOIN economic_transfer_queue_job_requesters r
ON r.queue_job_id = q.id AND r.user_id = ?
WHERE q.id = ? AND (q.created_by = ? OR r.user_id = ?)
LIMIT 1"
);
if (!$stmt) {
return null;
}
$stmt->bind_param('ii', $job_id, $created_by);
$stmt->bind_param('iiii', $created_by, $job_id, $created_by, $created_by);
if (!$stmt->execute()) {
$stmt->close();
return null;
@@ -179,7 +193,8 @@ class economic_transfer_queue
$offset = max(0, $offset);
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
@@ -203,7 +218,8 @@ class economic_transfer_queue
}
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
@@ -242,6 +258,9 @@ class economic_transfer_queue
global $db;
$user_id = max(0, $user_id);
if ($user_id < 1) {
return [];
}
$limit = max(1, min(100, $limit));
try {
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
@@ -262,7 +281,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id
AND d.user_id = $user_id
AND d.dismissed_status = q.status
WHERE q.created_by = $user_id
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
$transfer_condition
AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
@@ -355,7 +374,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id
AND d.user_id = $user_id
AND d.dismissed_status = q.status
WHERE q.created_by = $user_id
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
$transfer_condition
AND d.queue_job_id IS NULL
@@ -389,6 +408,9 @@ class economic_transfer_queue
if ($existing_job === null) {
throw new Exception('Queue job not found');
}
if ($created_by !== null && (int)($existing_job['created_by'] ?? 0) !== $created_by) {
throw new Exception('Only the queue job creator can retry this job');
}
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
throw new Exception('Only failed jobs can be retried');
}
@@ -610,11 +632,53 @@ class economic_transfer_queue
if ($collected_invoice_id < 1) {
throw new Exception('collected_invoice_id is required');
}
$collection_lock = $this->acquireCollectedInvoiceExportLock(
self::TYPE_COLLECTED_INVOICE_EXPORT,
$payload
);
$this->assertCollectedInvoiceExportIsStillEligible($payload);
$send_as_is = (bool)($payload['send_as_is'] ?? false);
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
}
/**
* Enqueue, worker execution, payments, and invoice-tree mutations share the
* same collection lock. The returned object intentionally stays in scope
* for the complete enqueue/export operation and releases in its destructor.
*
* @throws Exception
*/
private function acquireCollectedInvoiceExportLock(string $transfer_type, array $payload): ?order_payment_lock
{
if ($transfer_type !== self::TYPE_COLLECTED_INVOICE_EXPORT) {
return null;
}
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
if ($collected_invoice_id < 1) {
throw new Exception('collected_invoice_id is required');
}
$lock = order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id);
if ($lock === null) {
throw new Exception('Invoice collection is currently being changed or paid. Try again.');
}
return $lock;
}
/**
* Re-read eligibility after acquiring the collection lock so a queued job
* cannot export a collection that was booked or superseded while waiting.
*
* @throws Exception
*/
private function assertCollectedInvoiceExportIsStillEligible(array $payload): void
{
$collection = (new \objects\collected_order_invoices_o())->select(
(int)($payload['collected_invoice_id'] ?? 0)
);
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection);
}
private function updateProgress(int $job_id, int $percent, string $message): void
{
global $db;
@@ -727,6 +791,38 @@ class economic_transfer_queue
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
}
private function registerJobRequester(int $job_id, int $user_id): void
{
global $db;
if ($job_id < 1 || $user_id < 1) {
return;
}
$stmt = $db->prepare(
"INSERT INTO economic_transfer_queue_job_requesters (queue_job_id, user_id, requested_at)
VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE requested_at = VALUES(requested_at)"
);
if (!$stmt) {
throw new Exception('Failed to prepare queue requester registration');
}
$stmt->bind_param('ii', $job_id, $user_id);
if (!$stmt->execute()) {
$stmt->close();
throw new Exception('Failed to register queue requester');
}
$stmt->close();
}
private function jobVisibilitySql(string $alias, int $user_id): string
{
$user_id = max(0, $user_id);
return "($alias.created_by = $user_id OR EXISTS (
SELECT 1 FROM economic_transfer_queue_job_requesters requester
WHERE requester.queue_job_id = $alias.id AND requester.user_id = $user_id
))";
}
/**
* Release jobs stuck in PROCESSING due to crashes or killed workers.
*/
@@ -756,11 +852,14 @@ class economic_transfer_queue
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
}
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
};
if ($transfer_type === self::TYPE_COLLECTED_INVOICE_EXPORT) {
return $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by);
}
if (!in_array($transfer_type, [self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT], true)) {
$this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type);
}
return $this->normalizeOrderPayload($normalized_payload, $created_by);
}
/**
@@ -770,7 +869,7 @@ class economic_transfer_queue
{
$order_id = $payload['order_id'] ?? null;
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
$this->rejectPayload($created_by, 'order_id is required and must be a positive number');
}
$payload['order_id'] = (int)$order_id;
return $payload;
@@ -783,7 +882,7 @@ class economic_transfer_queue
{
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
$this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
}
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
@@ -804,17 +903,17 @@ class economic_transfer_queue
if ($numeric === 0 || $numeric === 1) {
return $numeric === 1;
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
return in_array($normalized, ['true', '1'], true);
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
@@ -840,8 +939,8 @@ class economic_transfer_queue
{
global $db;
$created_by = max(0, $created_by);
if ($target_value < 1 || $created_by < 1) {
// Active work is unique by transfer type and business target across all requesting users.
if ($target_value < 1) {
return null;
}
@@ -851,7 +950,6 @@ class economic_transfer_queue
WHERE transfer_type = ?
AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
AND created_by = ?
ORDER BY id DESC
LIMIT 1"
);
@@ -861,7 +959,7 @@ class economic_transfer_queue
$queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
if (!$stmt->execute()) {
$stmt->close();
return null;
@@ -54,6 +54,17 @@ class economic_transfer_queue_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters (
queue_job_id BIGINT UNSIGNED NOT NULL,
user_id INT NOT NULL,
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (queue_job_id, user_id),
INDEX idx_economic_transfer_queue_job_requesters_user (user_id, queue_job_id),
INDEX idx_economic_transfer_queue_job_requesters_job (queue_job_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
if ($discount_row === null) {
continue;
}
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$fixed_price = (float)$discount_row['fixed_price'];
$order_discount_total += (($base_price - $fixed_price) * $quantity);
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
return $total;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key];
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
? (int)$department_id
: null;
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
)) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
if ($scopedDepartmentId !== null) {
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $global_discount;
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
@@ -10,7 +10,7 @@ class economic_v2_revenue_statistics_service
private economic $economic;
/** @var array<int, array{customer_number:int,name:?string,barred:?bool,status:string}> */
/** @var array<int, array{customer_number: int, name: ?string, barred: ?bool, status: string}> */
private array $customer_cache = [];
public function __construct(?economic $economic = null)
@@ -44,7 +44,6 @@ class economic_v2_revenue_statistics_service
$summary = [
'invoice_count' => 0,
'line_count' => 0,
'unique_customers' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
@@ -398,7 +397,7 @@ class economic_v2_revenue_statistics_service
}
/**
* @return array{customer_number:int,name:?string,barred:?bool,status:string}
* @return array{customer_number: int, name: ?string, barred: ?bool, status: string}
*/
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
{
@@ -500,4 +499,3 @@ class economic_v2_revenue_statistics_service
return $data;
}
}
@@ -60,11 +60,13 @@ class economic_v2_schema_bootstrap
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL,
customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER discount"
);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN department_id INT NULL DEFAULT NULL
AFTER id"
);
}
self::$initialized = true;
}
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
array $metadata = [],
?int $fixed_price = null,
?int $department_id = null
): array {
$identity = [
'user_id' => $user_id,
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
if ($discount === null || (int)$discount === 0) {
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
$identity,
[
'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
],
$this->normalizeDatetime($effective_from),
$source,
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
string $timestamp,
?int $department_id = null
): ?array {
$identity = [
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$identity,
$timestamp,
'effective_from DESC, id DESC',
1
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
}
// Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_fixed_price ? ', po.fixed_price' : '') .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
['table' => 'price_overrides'],
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
$bucket[$action]++;
}
}
+91 -11
View File
@@ -34,6 +34,7 @@ use MailerSend\Helpers\Builder\Recipient;
use MailerSend\MailerSend;
use objects\bookings_o;
use objects\departments_o;
use objects\logs_o;
use objects\users_o;
use Psr\Http\Client\ClientExceptionInterface;
@@ -144,6 +145,15 @@ use Psr\Http\Client\ClientExceptionInterface;
];
// If the email is blacklisted, return without sending the email
if (in_array($to, $blacklisted_emails)) {
// Previously this was a silent return - ops could not tell whether a
// missing delivery was caused by the blacklist or a real provider
// outage. Emit a structured skip event before returning.
$context = [
'reason' => 'recipient_blacklisted',
'recipient' => $to,
'subject' => $subject,
];
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return;
}
// Send POST request to email service
@@ -164,12 +174,19 @@ use Psr\Http\Client\ClientExceptionInterface;
if ($attachments) {
$attachments = array_map(function ($attachment) {
// Read the data from the path (Attachment[0]) and set the filename (Attachment[1])
$attachment[0] = file_get_contents($attachment[0]);
if ($attachment[0] === false) {
throw new Exception('Failed to read file: ' . $attachment[0]);
$path = (string)$attachment[0];
$contents = file_get_contents($path);
if ($contents === false) {
// Capture the path before it is overwritten so the resulting
// exception message is useful in ops logs. Previously this
// threw with binary contents (because $attachment[0] had
// already been replaced by file_get_contents()'s output),
// making the failure essentially un-diagnosable.
throw new Exception('Failed to read attachment file: ' . $path);
}
$attachment[0] = $contents;
if (empty($attachment[1])) {
throw new Exception('Filename is empty');
throw new Exception('Attachment filename is empty (path: ' . $path . ')');
}
return new Attachment($attachment[0], $attachment[1]);
}, $attachments);
@@ -489,7 +506,13 @@ use Psr\Http\Client\ClientExceptionInterface;
{
// Validate the booking object
$order_booking->requireSelected();
if (!$order_booking->hasTransaction()) return; // Only send a wash certificate if the order has been created.
if (!$order_booking->hasTransaction()) {
// Previously this was a silent return which made wash-certificate
// delivery failures (e.g. k.sand@ksand.dk) impossible to diagnose
// without DB access. Emit a structured skip event before returning.
self::logWashCertificateSkip('no_transaction_email', (int)$order_booking->id, (int)$order_booking->customer_number->value());
return;
} // Only send a wash certificate if the order has been created.
// Get the order details
$order = $order_booking->getOrder();
// Get customer details
@@ -604,6 +627,14 @@ use Psr\Http\Client\ClientExceptionInterface;
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
$recipientEmail = trim((string)($recipient['email'] ?? ''));
if ($recipientEmail === '') {
// Recipient has no email address; surface the skip so an admin
// with no configured inbox can be fixed instead of silently
// dropping new-customer notifications.
$context = [
'reason' => 'superuser_recipient_empty_email',
'recipient_display_name' => trim((string)($recipient['display_name'] ?? '')),
];
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
continue;
}
@@ -612,12 +643,61 @@ use Psr\Http\Client\ClientExceptionInterface;
$recipientName = $recipientEmail;
}
$this->sendEmail(
$recipientEmail,
$recipientName,
'New customer registered on Truck Wash',
$message,
);
// Per-recipient try/catch so a single bad MailerSend response does
// not break delivery to the remaining superuser recipients - this
// loop is unprotected upstream and a transient 5xx would otherwise
// mean the rest of the team silently stops hearing about new
// customer registrations.
try {
$this->sendEmail(
$recipientEmail,
$recipientName,
'New customer registered on Truck Wash',
$message,
);
} catch (Exception $e) {
$context = [
'reason' => 'superuser_recipient_send_failed',
'recipient' => $recipientEmail,
'subject' => 'New customer registered on Truck Wash',
'error' => $e->getMessage(),
];
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
}
}
/**
* Record a structured "wash certificate was skipped" event.
*
* Mirrors the helper of the same name on order_bookings_o so that every
* silent-return path in the email delivery flow (this class plus the
* order-bookings wrapper) is observable from the same grep target.
*
* TODO: migrate to the project logger when one is available globally.
*
* @param array<string, mixed> $extra
*/
private static function logWashCertificateSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
{
$context = array_merge([
'reason' => $reason,
'booking_id' => $booking_id,
'customer_number' => $customer_number,
], $extra);
try {
(new logs_o())->add(
'email',
'global',
3,
0,
'WASH_CERT_SKIP',
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
} catch (\Throwable) {
// Logging must never block the booking flow.
}
// Also emit to PHP error stream so this is visible in container logs.
error_log('[wash-cert-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
}
+1 -31
View File
@@ -9,41 +9,11 @@ class encrypt implements encrypt_i
public function encrypt(string $data): string
{
// Debug:
return $data;
// Encrypt data
global $ENCRYPTION_KEY;
// Use AES 256 encryption
$cipher = "aes-256-cbc";
// Use the encryption key
$options = 0;
// Get the initialization vector
$iv_length = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($iv_length);
// Use the first 16 bytes of the initialization vector
$iv = substr($iv, 0, 16);
// Encrypt the data
$encrypted = openssl_encrypt($data, $cipher, $ENCRYPTION_KEY, $options, $iv);
// Save the initialization vector for decryption
return $iv . $encrypted;
}
public function decrypt(string $data): string
{
// Debug:
return $data;
// Decrypt data
global $ENCRYPTION_KEY;
// Use AES 256 encryption
$cipher = "aes-256-cbc";
// Use the encryption key and initialization vector
$options = 0;
// Get the initialization vector
$iv_length = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $iv_length);
// Get the encrypted data
$encrypted = substr($data, $iv_length);
// Decrypt the data
return openssl_decrypt($encrypted, $cipher, $ENCRYPTION_KEY, $options, $iv);
}
}
}
+75 -25
View File
@@ -5,9 +5,6 @@ namespace classes;
require_once WD . '/modules/entra/entra_c.php';
use entra\entra_c;
use Microsoft\Graph\GraphServiceClient;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContextBuilder;
class entra
@@ -23,42 +20,95 @@ class entra
$this->config = new entra_c();
}
public function get_users($array = false): array|object
public function get_users(bool $array = false): array
{
$graphClient = $this->getGraphClient();
$users = $graphClient->users()
->get()
->wait()
->getValue();
$accessToken = $this->requestAccessToken();
$usersResponse = $this->requestJson(
'https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,userPrincipalName',
['Authorization: Bearer ' . $accessToken]
);
$users = is_array($usersResponse['value'] ?? null) ? $usersResponse['value'] : [];
if (!$array) {
return $users;
}
$result = [];
foreach ( $users as $user ) {
foreach ($users as $user) {
if (!is_array($user)) {
continue;
}
$result[] = [
'id' => $user->getId(),
'displayName' => $user->getDisplayName(),
'mail' => $user->getMail(),
'userPrincipalName' => $user->getUserPrincipalName(),
'id' => $user['id'] ?? null,
'displayName' => $user['displayName'] ?? null,
'mail' => $user['mail'] ?? null,
'userPrincipalName' => $user['userPrincipalName'] ?? null,
];
}
return $result;
}
public function getGraphClient(): GraphServiceClient
private function requestAccessToken(): string
{
return new GraphServiceClient(
$this->getTokenRequestContext(),
$tenantId = trim((string)$this->config->tenant_id->getVariableValue());
$response = $this->requestJson(
'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/oauth2/v2.0/token',
['Content-Type: application/x-www-form-urlencoded'],
http_build_query([
'client_id' => (string)$this->config->client_id->getVariableValue(),
'client_secret' => (string)$this->config->client_secret->getVariableValue(),
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
])
);
$token = trim((string)($response['access_token'] ?? ''));
if ($token === '') {
throw new \RuntimeException('Microsoft Entra token response did not contain an access token.');
}
return $token;
}
public function getTokenRequestContext(): ClientCredentialContext
/**
* @param list<string> $headers
* @return array<string,mixed>
*/
private function requestJson(string $url, array $headers, ?string $postFields = null): array
{
return new ClientCredentialContext(
$this->config->tenant_id->getVariableValue(),
$this->config->client_id->getVariableValue(),
$this->config->client_secret->getVariableValue()
);
$curl = curl_init($url);
if ($curl === false) {
throw new \RuntimeException('Unable to initialize Microsoft Entra request.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
]);
if ($postFields !== null) {
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $postFields);
}
try {
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($body === false) {
throw new \RuntimeException('Microsoft Entra request failed: ' . curl_error($curl));
}
} finally {
curl_close($curl);
}
$decoded = json_decode((string)$body, true);
if ($status < 200 || $status >= 300 || !is_array($decoded)) {
$message = is_array($decoded)
? (string)($decoded['error_description'] ?? $decoded['error']['message'] ?? 'Unexpected response')
: 'Invalid JSON response';
throw new \RuntimeException('Microsoft Entra request failed with HTTP ' . $status . ': ' . $message);
}
return $decoded;
}
}
}
@@ -92,12 +92,17 @@ class error_report_service
throw new RuntimeException('Data collection acceptance is required.');
}
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$runtimeContext['screenshot_attachment'] = [
'status' => $storedScreenshot['status'],
'attached' => $storedScreenshot['key'] !== '',
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
'size_bytes' => (int)$storedScreenshot['size_bytes'],
];
$this->execute(
"INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function storeOptionalScreenshot(mixed $value, array $context): array
{
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
return $this->emptyScreenshotAttachment('invalid');
}
$dataUri = trim((string)($value ?? ''));
if ($dataUri === '') {
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
}
try {
$screenshot = self::decodeScreenshotDataUri($dataUri);
} catch (RuntimeException $exception) {
$message = strtolower($exception->getMessage());
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
}
try {
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
} catch (Throwable) {
return $this->emptyScreenshotAttachment('storage_failed');
}
return [
'key' => (string)($storedScreenshot['key'] ?? ''),
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
'status' => 'stored',
];
}
private function emptyScreenshotAttachment(string $status): array
{
return [
'key' => '',
'mime_type' => '',
'size_bytes' => 0,
'status' => $status,
];
}
private function contextScreenshotStatus(array $context): ?string
{
$attachment = $context['screenshot_attachment'] ?? null;
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
return $this->normalizeEmptyScreenshotStatus($status);
}
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
{
$status = strtolower(trim((string)$status));
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
return $status;
}
return null;
}
private function runtimeContext(array $payload, array $context): array
{
return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array
{
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'screenshot' => $hasScreenshot ? [
'mime_type' => $screenshotMimeType,
'size_bytes' => $screenshotSizeBytes,
] : null,
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
if ($hasScreenshot) {
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
}
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
-1
View File
@@ -10,7 +10,6 @@ require_once WD . '/modules/forms/form_helper_c.php';
use Exception;
use forms\form_helper_c;
use forms\objects\book_interior_wash_f;
use forms\objects\book_wash_f;
use objects\form_submissions_o;
use traits\form_t;
+23 -4
View File
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
use fxratesapi\fxratesapi_c;
use interfaces\fxratesapi_i;
use objects\fxratesapi_conversion_rates_o;
use Throwable;
class fxratesapi implements fxratesapi_i
{
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
// Validate the base and target currencies
self::requireValidCurrency($base);
self::requireValidCurrency($target);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key
self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
*/
function getDailyRequestCounter(): int
{
try {
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
} catch (Throwable) {
}
// Count the rows from the fxratesapi request log that was made today
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
$fxratesapi_lookups->getTodayCount();
return $fxratesapi_lookups->getTodayCount();
}
/**
* @throws Exception
*/
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
'base' => $base,
'target' => $target,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/**
* @inheritDoc
*/
@@ -515,4 +534,4 @@ class fxratesapi implements fxratesapi_i
throw new Exception('Failed to add request to log');
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
<?php
namespace classes;
/**
* Ensures additive audit columns used when invoice collections are superseded.
*/
class invoice_collection_schema_bootstrap
{
private const TABLE = 'collected_order_invoices';
private const LOCK_NAME = 'invoice_collection_schema_v1';
/** @var array<string, string> */
private const REQUIRED_COLUMNS = [
'superseded_by_collection_id' => 'INT NULL',
'superseded_at' => 'DATETIME NULL',
'superseded_by_user_id' => 'INT NULL',
];
public static function hasRequiredColumns(): bool
{
try {
global $db;
if (!self::canInspectSchema($db) || !self::tableExists($db)) {
return false;
}
if (self::allColumnsExist($db)) {
return true;
}
if (!self::acquireLock($db)) {
return false;
}
try {
// Another worker may have completed the additive migration while
// this request waited for the advisory lock.
foreach (self::REQUIRED_COLUMNS as $column => $definition) {
if (self::columnExists($db, $column)) {
continue;
}
$result = $db->query(
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}"
);
if ($result === false) {
return false;
}
}
if (!self::allColumnsExist($db)) {
return false;
}
return true;
} finally {
self::releaseLock($db);
}
} catch (\Throwable) {
// Schema readiness is a capability gate. It must never break the
// legacy invoicing routes when DDL or metadata access is unavailable.
return false;
}
}
private static function canInspectSchema(mixed $db): bool
{
return is_object($db)
&& method_exists($db, 'query')
&& method_exists($db, 'escape_string')
&& method_exists($db, 'getDatabase');
}
private static function tableExists(object $db): bool
{
return self::informationSchemaCount(
$db,
'information_schema.TABLES',
'TABLE_NAME',
self::TABLE
) > 0;
}
private static function columnExists(object $db, string $column): bool
{
return self::informationSchemaCount(
$db,
'information_schema.COLUMNS',
'COLUMN_NAME',
$column
) > 0;
}
private static function informationSchemaCount(
object $db,
string $informationSchemaTable,
string $nameField,
string $name
): int {
$database = $db->escape_string((string)$db->getDatabase());
$name = $db->escape_string($name);
$result = $db->query(
"SELECT COUNT(*) AS c FROM {$informationSchemaTable} "
. "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' "
. "AND {$nameField} = '{$name}'"
);
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
throw new \RuntimeException('Invoice collection schema inspection failed');
}
$row = $result->fetch_assoc();
if (!is_array($row) || !array_key_exists('c', $row)) {
throw new \RuntimeException('Invoice collection schema inspection returned an invalid result');
}
return (int)$row['c'];
}
private static function allColumnsExist(object $db): bool
{
foreach (array_keys(self::REQUIRED_COLUMNS) as $column) {
if (!self::columnExists($db, $column)) {
return false;
}
}
return true;
}
private static function acquireLock(object $db): bool
{
$result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired");
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
return false;
}
$row = $result->fetch_assoc();
return is_array($row) && (int)($row['acquired'] ?? 0) === 1;
}
private static function releaseLock(object $db): void
{
try {
$db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')");
} catch (\Throwable) {
// The connection also releases advisory locks automatically.
}
}
}
@@ -57,7 +57,10 @@ class invoice_period_flag_schema_bootstrap
);
products_schema_bootstrap::ensureTables();
xlvask_usage_logs_schema_bootstrap::ensureTables();
// Invoice-period flags remain available while the separately operated
// XL Vask automation migration is pending. XL Vask-specific flag
// detection already fails closed when its optional schema is absent.
self::$initialized = true;
}
@@ -30,6 +30,8 @@ class invoice_period_flag_service
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
@@ -74,8 +76,11 @@ class invoice_period_flag_service
$this->nullableIntSql($userId > 0 ? $userId : null)
);
$db->query($sql);
$flagId = (int)$db->insert_id();
return $this->getStoredFlag((int)$db->insert_id());
$this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($flagId);
}
public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array
@@ -105,6 +110,8 @@ class invoice_period_flag_service
);
$db->query($sql);
$this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($id);
}
@@ -225,6 +232,61 @@ class invoice_period_flag_service
return $types;
}
/**
* Add only aggregate active manual-flag counts for readiness derivation.
* Flag details remain absent when the caller lacks list_invoice_period_flags.
*
* @param array<string,array<int,array<string,mixed>>> $types
* @param int[]|null $onlyCustomerNumbers
* @return array<string,array<int,array<string,mixed>>>
*/
public function applyManualFlagCountsToPeriodTypes(
array $types,
string $dateFrom,
string $dateTo,
?array $onlyCustomerNumbers = null
): array {
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
return $this->applyManualFlagCounts($types, $manualFlags);
}
private function applyManualFlagCounts(array $types, array $manualFlags): array
{
$flagsByCustomerNumber = [];
foreach ($manualFlags as $flag) {
$customerNumber = (int)($flag['customer_number'] ?? 0);
if ($customerNumber > 0) {
$flagsByCustomerNumber[$customerNumber][] = $flag;
}
}
foreach ($types as $typeName => $customers) {
foreach ($customers as $index => $customer) {
$customerNumber = (int)($customer['customer_number'] ?? 0);
$customerFlags = $this->flagsForCustomerCard(
$customer,
$flagsByCustomerNumber[$customerNumber] ?? [],
(string)$typeName
);
$activeManualCount = count(array_filter(
$customerFlags,
static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL
&& ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE
));
$existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0);
$types[$typeName][$index]['flag_counts'] = [
'manual' => $activeManualCount,
'automatic' => $existingAutomaticCount,
'total' => $activeManualCount + $existingAutomaticCount,
];
unset($types[$typeName][$index]['flags']);
}
}
return $types;
}
private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
{
$transactionIds = [];
@@ -329,6 +391,12 @@ class invoice_period_flag_service
}
}
private function refreshManualFlagsCacheAfterMutation(): void
{
$this->manualFlagsInstanceCache = null;
$this->warmManualFlagsCache();
}
private function fetchActiveManualFlagsFromDb(): array
{
global $db;
@@ -688,6 +756,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -710,8 +779,21 @@ class invoice_period_flag_service
p.max_quantity_per_order,
c.name AS category_name,
pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage,
category_discount.percentage AS category_discount_percentage
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END AS category_discount_percentage
FROM orders o
LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -720,11 +802,12 @@ class invoice_period_flag_service
GROUP BY customer_number
) u ON u.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN departments d ON d.id = o.department_id
LEFT JOIN products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN (
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0
@@ -741,6 +824,32 @@ class invoice_period_flag_service
) category_discount
ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id";
@@ -804,11 +913,16 @@ class invoice_period_flag_service
{
global $db;
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
$result = $db->query(
"SELECT u.customer_number, ca.attribute
"SELECT u.customer_number, ca.attribute, cp.product_id
FROM customer_attributes ca
JOIN users u ON u.id = ca.user_id
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE 1=1 {$customerFilter}"
);
@@ -819,7 +933,11 @@ class invoice_period_flag_service
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
$attributes[$customerNumber][(string)$row['attribute']] = true;
$attribute = (string)$row['attribute'];
$attributes[$customerNumber][$attribute] = true;
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
}
}
return $attributes;
@@ -830,6 +948,8 @@ class invoice_period_flag_service
$flags = [];
$orders = [];
$collectionOrders = [];
$matchingRules = static fn(int $customerNumber, int $productId): array =>
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
foreach ($rows as $row) {
$customerNumber = (int)$row['customer_number'];
@@ -855,57 +975,17 @@ class invoice_period_flag_service
continue;
}
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
&& (int)($row['related_item_id'] ?? 0) > 0
&& (int)($row['item_price'] ?? 0) > 0) {
$flags[] = $this->automaticFlag(
'customer_rule_restrict_addon_services',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
$flags[] = $this->automaticFlag(
'customer_rule_restrict_tank_cleaning',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
$flags[] = $this->automaticFlag(
'customer_rule_only_tank_cleaning',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
$restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
$productRuleDefinitions = [
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
];
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
&& $this->rowMatchesProductTerms($row, $terms)) {
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
if (isset($productRuleDefinitions[$attribute])) {
$flags[] = $this->automaticFlag(
$definitionKey,
$productRuleDefinitions[$attribute],
'order_item',
(int)$row['order_item_id'],
null,
@@ -915,6 +995,19 @@ class invoice_period_flag_service
);
}
}
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
$flags[] = $this->automaticFlag(
'customer_rule_exempt_from_administration_fees',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
}
foreach ($orders as $orderId => $row) {
@@ -1204,7 +1297,34 @@ class invoice_period_flag_service
}
}
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
$hasReg2 = [];
$emptyReg2 = [];
foreach ($primaryRows as $row) {
if (trim((string)($row['reg_2'] ?? '')) === '') {
$emptyReg2[] = $row;
} else {
$hasReg2[] = $row;
}
}
$history = [];
if (!empty($emptyReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($emptyReg2, 'reg_1'),
true
);
}
if (!empty($hasReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($hasReg2, 'reg_1'),
false
);
}
foreach ($primaryRows as $row) {
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
if ($reg === '' || !isset($history[$reg])) {
@@ -1375,6 +1495,7 @@ class invoice_period_flag_service
{
$product = (string)($params['product'] ?? 'Item');
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
$washId = (string)($params['wash_id'] ?? '');
return match ($definitionKey) {
'price_mismatch' => "{$product} product price differs from expected.",
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
@@ -1394,7 +1515,9 @@ class invoice_period_flag_service
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
'xlvask_missing_order_link' => $washId === ''
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
default => "Automatically detected invoice-period issue.",
};
}
@@ -1421,7 +1544,8 @@ class invoice_period_flag_service
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
],
'xlvask_missing_order_link' => [
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
],
default => [],
@@ -1681,7 +1805,7 @@ class invoice_period_flag_service
return $map;
}
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
{
global $db;
@@ -1702,6 +1826,16 @@ class invoice_period_flag_service
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
return "'" . $db->escape_string($registrationNumber) . "'";
}, array_keys($registrations)));
// Restrict historical orders to those whose reg_2 status matches the current rows:
// - null → no filter (default behaviour, backwards compatible)
// - true → reg_2 empty (single-tractor orders only)
// - false → reg_2 non-empty (tractor-trailer combo orders only)
$reg2Filter = '';
if ($requireReg2Empty === true) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
} elseif ($requireReg2Empty === false) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
}
$result = $db->query(
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
FROM orders o
@@ -1715,6 +1849,7 @@ class invoice_period_flag_service
AND COALESCE(oi.related_item_id, 0) = 0
AND COALESCE(o.reg_1, '') <> ''
AND o.reg_1 IN ({$registrationFilter})
{$reg2Filter}
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
ORDER BY reg, usage_count DESC, oi.product_id ASC"
);
@@ -1921,7 +2056,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
if ($customMissingPrice) {
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
}
$fixedPrice = $this->rowProductFixedPrice($row);
if ($fixedPrice !== null) {
return $fixedPrice;
}
$base = $row['department_price'] !== null
? (int)$row['department_price']
: (int)($row['product_base_price'] ?? 0);
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100)));
}
@@ -1929,13 +2076,18 @@ class invoice_period_flag_service
private function priceBreakdown(array $row, int $expected): array
{
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +2096,36 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
private function discountBreakdown(array $row): array
{
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [
'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount,
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
'applied_discount_percentage' => $appliedDiscount,
];
}
private function rowProductFixedPrice(array $row): ?int
{
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
? (int)$row['product_fixed_price']
: null;
}
private function economicCustomerDiscountPercentage(array $row): int
{
$customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2034,12 +2201,6 @@ class invoice_period_flag_service
return false;
}
private function rowIsTankCleaningProduct(array $row): bool
{
return (int)($row['product_category'] ?? 0) === 5
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private function isIncludedOrderItem(array $row): bool
{
$value = $row['item_include_in_invoice'] ?? 1;
+2 -2
View File
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
return count($objects['Contents'] ?? []) > 0;
}
public function getInvoiceDownloadUrl(int $id): string
public function getInvoiceDownloadUrl(int|string $id): string
{
return self::getPresignedUrl('invoice_' . $id . '.pdf');
}
@@ -51,4 +51,4 @@ class invoice_store implements minio_invoices_i
{
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
}
}
}
@@ -137,7 +137,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
if ($cached_result !== null) {
$this->last_timings['cache_hit'] = 1;
return $cached_result;
return $this->completeRecognition($started_at, $cached_result);
}
}
}
@@ -202,7 +202,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
return $this->completeRecognition($started_at, $recognized_result);
}
$recognized_result = [
@@ -211,12 +211,19 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
} finally {
return $this->completeRecognition($started_at, $recognized_result);
} catch (\Throwable $exception) {
$this->last_timings['total'] = $this->elapsedMs($started_at);
throw $exception;
}
}
private function completeRecognition(float $started_at, array $result): array
{
$this->last_timings['total'] = $this->elapsedMs($started_at);
return $result;
}
private static function clientDisconnectAbortCallback(): callable
{
return static function (): int {
@@ -244,6 +251,11 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
return (string)$configured;
}
public static function configuredApiBaseUrl(): string
{
return self::normalizeApiUrl(self::configuredApiUrl());
}
private static function normalizeApiUrl(string $api_url): string
{
$api_url = trim($api_url);
@@ -0,0 +1,579 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
/**
* Issues narrowly scoped bearer grants which can be exchanged once for a normal
* employee session. Only a SHA-256 digest is persisted. The bearer is derived
* under the server encryption key so the same authorized idempotent request can
* recover an unconsumed grant after a lost response without storing plaintext.
*/
class limited_backoffice_login_grant_service
{
public const PURPOSE_EMPLOYEE_DIRECT_LOGIN = 'limited_backoffice_employee_login';
public const DEFAULT_TTL_SECONDS = 300;
public const MIN_TTL_SECONDS = 60;
public const MAX_TTL_SECONDS = 900;
public function __construct()
{
limited_backoffice_schema_bootstrap::ensureTables();
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function create(users_o $manager, int $employeeId, array $payload): array
{
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
$purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN));
if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) {
throw new limited_backoffice_exception('Unsupported login grant purpose.', 400);
}
$ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS);
$expiresAt = time() + $ttlSeconds;
$preflight = ($payload['preflight'] ?? false) === true;
$base = [
'employee_id' => $employeeId,
'purpose' => $purpose,
'ttl_seconds' => $ttlSeconds,
'expires_at' => gmdate('c', $expiresAt),
'one_time' => true,
];
if ($preflight) {
return $base + ['preflight' => true];
}
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) {
throw new limited_backoffice_exception(
'idempotency_key must contain between 16 and 128 characters.',
400
);
}
$idempotencyKeyHash = hash('sha256', $idempotencyKey);
$grantId = bin2hex(random_bytes(16));
$bearer = $this->bearerForIdempotency(
(int)$manager->id,
$employeeId,
$purpose,
$ttlSeconds,
$idempotencyKey
);
$secretHash = hash('sha256', $bearer);
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 3; $attempt++) {
$mysqli->begin_transaction();
try {
if (!$this->lockActiveManagedEmployee($employeeId)) {
throw new limited_backoffice_exception(
'Cannot create a login grant for an inactive employee.',
409
);
}
// Target-first ordering matches employee update/deletion. Two
// cross-managing actors can still form a cycle, so deadlock
// victims are retried below with the same idempotency identity.
$authorizedActor = $this->lockAuthorizedActor($manager);
(new limited_backoffice_service())->assertEmployeeLoginTarget(
$authorizedActor['manager'],
$employeeId,
$authorizedActor['group_id']
);
$existing = $this->findIdempotentGrant(
(int)$manager->id,
$employeeId,
$purpose,
$idempotencyKeyHash
);
if ($existing !== null) {
if ($this->isReplayableGrant($existing, $secretHash)) {
$mysqli->commit();
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
}
throw $this->duplicateGrantException($existing);
}
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_login_grants`
(`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`,
`idempotency_key_hash`, `expires_at`)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to prepare login grant.', 500);
}
$actorUserId = (int)$manager->id;
$statement->bind_param(
'ssiissi',
$grantId,
$secretHash,
$employeeId,
$actorUserId,
$purpose,
$idempotencyKeyHash,
$expiresAt
);
try {
$statement->execute();
} finally {
$statement->close();
}
$mysqli->commit();
break;
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\mysqli_sql_exception $exception) {
$mysqli->rollback();
$errorCode = (int)$exception->getCode();
if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) {
usleep(1000 * ($attempt + 1));
continue;
}
if ($errorCode === 1062) {
$existing = $this->findIdempotentGrant(
(int)$manager->id,
$employeeId,
$purpose,
$idempotencyKeyHash
);
if ($existing !== null) {
if ($this->isReplayableGrant($existing, $secretHash)) {
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
}
throw $this->duplicateGrantException($existing);
}
}
throw new limited_backoffice_exception('Unable to create login grant.', 500);
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to create login grant.', 500);
}
}
$this->audit(
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED',
'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId
);
return $this->grantResult($employeeId, $purpose, $bearer, [
'grant_id' => $grantId,
'expires_at' => $expiresAt,
]);
}
/**
* @return array{employee_id:int,token:string}
*/
public function exchange(string $bearer): array
{
if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) {
throw $this->invalidGrantException();
}
$mysqli = $this->mysqli();
$secretHash = hash('sha256', $bearer);
$mysqli->begin_transaction();
try {
// Resolve the target without locking, then lock employee -> grant. Employee
// deactivation uses the same order, preventing a direct-login session from
// surviving a concurrent deactivation and avoiding inverse-order deadlocks.
$targetLookup = $mysqli->prepare(
'SELECT `target_user_id`
FROM `limited_backoffice_login_grants`
WHERE `secret_hash` = ?
LIMIT 1'
);
if ($targetLookup === false) {
throw new \RuntimeException('Unable to prepare login grant target lookup.');
}
$targetLookup->bind_param('s', $secretHash);
$targetLookup->execute();
$target = $targetLookup->get_result()->fetch_assoc() ?: null;
$targetLookup->close();
if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) {
throw $this->invalidGrantException();
}
$statement = $mysqli->prepare(
'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`,
`consumed_at`, `revoked_at`
FROM `limited_backoffice_login_grants`
WHERE `secret_hash` = ?
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare login grant exchange.');
}
$statement->bind_param('s', $secretHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if (
$row === null
|| $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN
|| $row['consumed_at'] !== null
|| $row['revoked_at'] !== null
|| (int)$row['expires_at'] <= time()
|| (int)$row['target_user_id'] !== (int)$target['target_user_id']
) {
throw $this->invalidGrantException();
}
$grantRowId = (int)$row['id'];
$consume = $mysqli->prepare(
'UPDATE `limited_backoffice_login_grants`
SET `consumed_at` = UTC_TIMESTAMP()
WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL
LIMIT 1'
);
if ($consume === false) {
throw new \RuntimeException('Unable to prepare login grant consumption.');
}
$consume->bind_param('i', $grantRowId);
$consume->execute();
$affectedRows = $consume->affected_rows;
$consume->close();
if ($affectedRows !== 1) {
throw $this->invalidGrantException();
}
$employeeId = (int)$row['target_user_id'];
$token = (new authentication())->create_employee_token($employeeId);
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to exchange login grant.', 500);
}
$this->audit(
$employeeId,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED',
'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId
);
return ['employee_id' => $employeeId, 'token' => $token];
}
/**
* @return array{employee_id:int,revoked_count:int}
*/
public function revokeForEmployee(users_o $manager, int $employeeId): array
{
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
$statement = $this->mysqli()->prepare(
'UPDATE `limited_backoffice_login_grants`
SET `revoked_at` = UTC_TIMESTAMP()
WHERE `target_user_id` = ?
AND `consumed_at` IS NULL
AND `revoked_at` IS NULL
AND `expires_at` >= ?'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500);
}
$now = time();
$statement->bind_param('ii', $employeeId, $now);
$statement->execute();
$revokedCount = $statement->affected_rows;
$statement->close();
$this->audit(
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED',
'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId
);
return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount];
}
private function ttlSeconds(mixed $value): int
{
if (is_string($value) && ctype_digit($value)) {
$value = (int)$value;
}
if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) {
throw new limited_backoffice_exception(
'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.',
400
);
}
return $value;
}
/**
* @return array<string, mixed>|null
*/
private function findIdempotentGrant(
int $actorUserId,
int $employeeId,
string $purpose,
string $idempotencyKeyHash
): ?array {
$statement = $this->mysqli()->prepare(
'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`,
`consumed_at`, `revoked_at`
FROM `limited_backoffice_login_grants`
WHERE `actor_user_id` = ?
AND `target_user_id` = ?
AND `purpose` = ?
AND `idempotency_key_hash` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500);
}
$statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
return $row;
}
private function bearerForIdempotency(
int $actorUserId,
int $employeeId,
string $purpose,
int $ttlSeconds,
string $idempotencyKey
): string {
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
if ($key === '') {
throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503);
}
return 'lbg_' . hash_hmac(
'sha256',
$actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey,
$key
);
}
private function isReplayableGrant(array $row, string $secretHash): bool
{
return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash)
&& ($row['consumed_at'] ?? null) === null
&& ($row['revoked_at'] ?? null) === null
&& (int)($row['expires_at'] ?? 0) > time();
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function grantResult(
int $employeeId,
string $purpose,
string $bearer,
array $row
): array {
$expiresAt = (int)$row['expires_at'];
return [
'employee_id' => $employeeId,
'purpose' => $purpose,
'ttl_seconds' => max(0, $expiresAt - time()),
'expires_at' => gmdate('c', $expiresAt),
'one_time' => true,
'preflight' => false,
'grant_id' => (string)$row['grant_id'],
// The fragment avoids ingress request logs and Referer propagation.
'login_path' => '/login/qr#grant=' . rawurlencode($bearer),
'exchange_path' => '/auth/limited-backoffice-login-grants/exchange',
];
}
private function duplicateGrantException(array $row): limited_backoffice_exception
{
return new limited_backoffice_exception(
'A login grant already exists for this idempotency key.',
409,
[
'message' => 'A login grant already exists for this idempotency key.',
'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT',
'grant_id' => (string)$row['grant_id'],
'employee_id' => (int)$row['target_user_id'],
'purpose' => (string)$row['purpose'],
'expires_at' => gmdate('c', (int)$row['expires_at']),
'consumed' => $row['consumed_at'] !== null,
'revoked' => $row['revoked_at'] !== null,
]
);
}
private function invalidGrantException(): limited_backoffice_exception
{
return new limited_backoffice_exception('Invalid or expired login grant.', 401);
}
private function lockActiveManagedEmployee(int $employeeId): bool
{
$statement = $this->mysqli()->prepare(
'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id`
FROM `limited_backoffice_employees` lbe
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to lock login grant employee.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$employee = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if ($employee === null) {
return false;
}
$groupId = (int)$employee['group_id'];
$managedGroupId = (int)$employee['managed_group_id'];
if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) {
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
}
// Lock the complete permission range so role changes cannot add elevated
// capabilities between validation and token creation.
$permissions = $this->mysqli()->prepare(
'SELECT `permission`
FROM `groups_permissions`
WHERE `group_id` = ?
FOR UPDATE'
);
if ($permissions === false) {
throw new limited_backoffice_exception('Unable to validate login grant role.', 500);
}
$permissions->bind_param('i', $groupId);
$permissions->execute();
$result = $permissions->get_result();
while ($row = $result->fetch_assoc()) {
if ((string)($row['permission'] ?? '') === 'superuser') {
$permissions->close();
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
}
}
$permissions->close();
$groupUsers = $this->mysqli()->prepare(
'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE'
);
if ($groupUsers === false) {
throw new limited_backoffice_exception('Unable to validate login grant group.', 500);
}
$groupUsers->bind_param('i', $groupId);
$groupUsers->execute();
$groupUserResult = $groupUsers->get_result();
$userCount = 0;
while ($groupUserResult->fetch_assoc() !== null) {
$userCount++;
}
$groupUsers->close();
if ($userCount !== 1) {
throw new limited_backoffice_exception('Login grant target group is shared.', 403);
}
return true;
}
/**
* @return array{manager:users_o,group_id:int}
*/
private function lockAuthorizedActor(users_o $manager): array
{
$actorUserId = (int)$manager->id;
$statement = $this->mysqli()->prepare(
'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to lock login grant actor.', 500);
}
$statement->bind_param('i', $actorUserId);
$statement->execute();
$actor = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if (
$actor === null
|| (int)$actor['group_id'] <= 0
|| account_deletion_service::principalIsBlocked('customer', $actorUserId)
) {
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
}
$groupId = (int)$actor['group_id'];
if ($groupId !== 1) {
$requiredPermissions = [
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
];
// Lock the actor's complete permission set, including every department_access_* row
// consumed by assertEmployeeLoginTarget.
// The group_id range lock prevents concurrent role replacement
// from revoking scope between authorization and grant insertion.
$permissions = $this->mysqli()->prepare(
'SELECT `permission`
FROM `groups_permissions`
WHERE `group_id` = ?
FOR UPDATE'
);
if ($permissions === false) {
throw new limited_backoffice_exception('Unable to validate login grant actor.', 500);
}
$permissions->bind_param('i', $groupId);
$permissions->execute();
$result = $permissions->get_result();
$granted = [];
while ($row = $result->fetch_assoc()) {
$granted[] = (string)$row['permission'];
}
$permissions->close();
if (array_diff($requiredPermissions, $granted) !== []) {
throw new limited_backoffice_exception(
'Login grant actor is no longer authorized.',
403
);
}
}
$currentManager = (new users_o())->getUserById($actorUserId);
if (!$currentManager->exists()) {
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
}
return [
'manager' => $currentManager,
'group_id' => $groupId,
];
}
private function audit(int $actorUserId, string $event, string $message): void
{
try {
(new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message);
} catch (\Throwable) {
// Audit logging must not expose a bearer or block the grant lifecycle.
}
}
private function mysqli(): \mysqli
{
global $db;
return $db->conn();
}
}
@@ -36,6 +36,45 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`grant_id` CHAR(32) NOT NULL,
`secret_hash` CHAR(64) NOT NULL,
`target_user_id` INT NOT NULL,
`actor_user_id` INT NOT NULL,
`purpose` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NULL,
`expires_at` BIGINT UNSIGNED NOT NULL,
`consumed_at` DATETIME NULL,
`revoked_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_user_id` INT NOT NULL,
`action_type` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NOT NULL,
`payload_hash` CHAR(64) NOT NULL,
`result_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
<?php
namespace classes;
class module_usage_registry
{
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
public function all(): array
{
return array_map(
fn(array $descriptor): array => $this->withDefaults($descriptor),
[
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'lookup_calls',
'metric_label' => 'License plate lookups',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'motorapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'motorapi_lookups',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'provider_usage',
'metric_label' => 'Provider usage',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
],
[
'module_key' => 'fxratesapi',
'module_label' => 'FXRatesAPI',
'metric_key' => 'rate_fetch_calls',
'metric_label' => 'Currency rate fetches',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'fxratesapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'fxratesapi_conversion_rates',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'virkdata',
'module_label' => 'VirkData',
'metric_key' => 'company_search_calls',
'metric_label' => 'Company searches',
'unit' => 'calls',
'period' => 'month',
'source' => 'internal_counter',
'default_enforce_mode' => 'observe',
'config_module' => 'virkdata',
'config_variable' => 'monthly_limit',
'config_type' => 'int',
'legacy_log_module' => 'VIRKDATA',
'legacy_log_action' => 'VIRKDATA_SEARCH',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'licenseplaterecognizer',
'module_label' => 'License Plate Recognizer',
'metric_key' => 'plate_recognition_calls',
'metric_label' => 'Plate recognition calls',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
'default_enforce_mode' => 'observe',
'primary' => true,
],
[
'module_key' => 'email',
'module_label' => 'Email',
'metric_key' => 'mailersend_messages',
'metric_label' => 'MailerSend messages',
'unit' => 'messages',
'period' => 'provider',
'source' => 'provider_snapshot',
],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
]
);
}
public function find(string $moduleKey, string $metricKey): ?array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
$metricKey = $this->normalizeMetricKey($metricKey);
foreach ($this->all() as $descriptor) {
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
return $descriptor;
}
}
return null;
}
public function forModule(string $moduleKey): array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
return array_values(array_filter(
$this->all(),
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
));
}
public function normalizeModuleKey(string $moduleKey): string
{
return strtolower(trim($moduleKey));
}
public function normalizeMetricKey(string $metricKey): string
{
return strtolower(trim($metricKey));
}
private function withDefaults(array $descriptor): array
{
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
return $descriptor;
}
}
@@ -0,0 +1,119 @@
<?php
namespace classes;
class module_usage_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_counters (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
scope_id VARCHAR(191) NOT NULL DEFAULT '',
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
period_start DATETIME NOT NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
limit_quantity DECIMAL(20,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ok',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
KEY idx_module_usage_counters_status (status, updated_at),
KEY idx_module_usage_counters_scope (scope_type, scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'provider',
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
period_start DATETIME NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NULL,
limit_quantity DECIMAL(20,4) NULL,
remaining_quantity DECIMAL(20,4) NULL,
usage_percent DECIMAL(8,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
raw_payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
KEY idx_module_usage_snapshots_status (status, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_quota_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
status_code INT NOT NULL DEFAULT 0,
data LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_logs_module_created (module, created_at),
KEY idx_module_usage_logs_action_created (action, created_at),
KEY idx_module_usage_logs_status_created (status_code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
self::$initialized = true;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
@@ -0,0 +1,999 @@
<?php
namespace classes;
use Exception;
use mysqli_result;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class module_usage_service
{
use boolean_normalization_t;
private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null)
{
module_usage_schema_bootstrap::ensureTables();
$this->registry = $registry ?? new module_usage_registry();
}
public function summary(array $filters = []): array
{
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = [];
foreach ($this->registry->all() as $descriptor) {
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
continue;
}
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
continue;
}
$metric = $this->currentMetric($descriptor, $date);
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
continue;
}
$metrics[] = $metric;
}
$modules = [];
foreach ($metrics as $metric) {
$moduleKey = $metric['module_key'];
if (!isset($modules[$moduleKey])) {
$modules[$moduleKey] = [
'key' => $moduleKey,
'label' => $metric['module_label'],
'status' => 'ok',
'metrics' => [],
];
}
$modules[$moduleKey]['metrics'][] = $metric;
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
}
return [
'generated_at' => date('c'),
'filters' => [
'module' => $moduleFilter !== '' ? $moduleFilter : null,
'period' => $periodFilter !== '' ? $periodFilter : null,
'status' => $statusFilter !== '' ? $statusFilter : null,
'date' => $date,
],
'modules' => array_values($modules),
'metrics' => $metrics,
];
}
public function moduleDetail(string $moduleKey, array $filters = []): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptors = $this->registry->forModule($moduleKey);
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
return [
'generated_at' => date('c'),
'module_key' => $moduleKey,
'metrics' => $metrics,
'history' => $this->historyForModule($moduleKey, $filters),
];
}
public function metricsForModule(string $moduleKey): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
return array_map(
fn(array $descriptor): array => $this->currentMetric($descriptor),
$this->registry->forModule($moduleKey)
);
}
/**
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
*
* @throws Exception
*/
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0) {
return $this->currentMetric($descriptor);
}
$setting = $this->settingFor($descriptor);
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$limit = $this->resolveLimitQuantity($descriptor);
if ($limit === null) {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->numberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = CASE
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
ELSE 'ok'
END
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
);
if ((int)$db->conn()->affected_rows <= 0) {
throw new Exception($this->quotaExceededMessage($descriptor));
}
return $this->currentMetric($descriptor);
}
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0 || !$this->databaseReady()) {
return $this->currentMetric($descriptor);
}
$limit = $this->resolveLimitQuantity($descriptor);
$setting = $this->settingFor($descriptor);
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->nullableNumberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
$statusSql = (($setting['enabled'] ?? true) !== true)
? $this->sqlString('disabled')
: "CASE
WHEN {$limitSql} IS NULL THEN 'unlimited'
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
WHEN {$limitSql} <= 0 THEN 'ok'
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
ELSE 'ok'
END";
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = {$statusSql}
WHERE {$where}"
);
return $this->currentMetric($descriptor);
}
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$metric = $this->currentMetric($descriptor);
return (int)floor((float)($metric['used'] ?? 0));
}
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$setting = $this->settingFor($descriptor);
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
if (!in_array($enforceMode, ['observe', 'block'], true)) {
throw new Exception('Invalid enforce mode.');
}
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
? (float)$payload['soft_limit_percent']
: (float)$setting['soft_limit_percent'];
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
throw new Exception('Soft limit percent must be between 1 and 100.');
}
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
throw new Exception('quota_not_writable');
}
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
throw new Exception('Limit must be a non-negative integer.');
}
$this->writeConfigLimit($descriptor, (int)$limitValue);
}
if ($this->databaseReady()) {
global $db;
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
$enabledSql = $enabled ? '1' : '0';
$modeSql = $this->sqlString($enforceMode);
$softLimitSql = $this->numberSql($softLimitPercent);
$db->query(
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
enforce_mode = VALUES(enforce_mode),
soft_limit_percent = VALUES(soft_limit_percent)"
);
}
return $this->currentMetric($descriptor);
}
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptor = null;
foreach ($this->registry->forModule($moduleKey) as $candidate) {
if (($candidate['source'] ?? '') === 'provider_snapshot') {
$descriptor = $candidate;
break;
}
}
if ($descriptor === null) {
return null;
}
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
return null;
}
if ($remaining === null && $used !== null && $limit !== null) {
$remaining = max(0.0, $limit - $used);
}
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
$percent = round(($used / $limit) * 100, 4);
}
$status = (!$usageAvailable || $unavailableReason !== '')
? 'unknown'
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
if ($this->databaseReady()) {
try {
global $db;
$db->query(
"INSERT INTO module_usage_snapshots
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
'provider',
'provider',
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->nullableNumberSql($used) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->nullableNumberSql($remaining) . ",
" . $this->nullableNumberSql($percent) . ",
" . $this->sqlString($status) . ",
" . $this->jsonSql($payload) . ",
" . $this->sqlString(date('Y-m-d H:i:s')) . "
)"
);
} catch (Throwable) {
// Provider snapshots are observability data. They must never break probes.
}
}
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
}
public function primarySystemUsage(array $metrics): ?array
{
$metrics = array_values(array_filter(
$metrics,
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
));
if ($metrics === []) {
return null;
}
usort($metrics, function (array $left, array $right): int {
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
}
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
return $rightRank <=> $leftRank;
});
$metric = $metrics[0];
return [
'provider' => $metric['module_key'],
'metric_key' => $metric['metric_key'],
'unit' => $metric['unit'],
'period' => $metric['period'],
'calls_used' => $metric['used'],
'quota_calls' => $metric['limit'],
'calls_remaining' => $metric['remaining'],
'usage_percent' => $metric['usage_percent'],
'status' => $metric['status'],
'source' => $metric['source'],
'enforce_mode' => $metric['enforce_mode'],
];
}
public function currentMetric(array $descriptor, ?string $date = null): array
{
$descriptor = $this->normalizeDescriptor($descriptor);
$setting = $this->settingFor($descriptor);
$window = $this->periodWindow((string)$descriptor['period'], $date);
$limit = $this->resolveLimitQuantity($descriptor);
$used = null;
$updatedAt = null;
$historyAvailable = false;
$snapshotExtra = [];
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
$snapshot = $this->latestProviderSnapshot($descriptor);
if ($snapshot !== null) {
$used = $snapshot['used_quantity'];
$limit = $snapshot['limit_quantity'];
$updatedAt = $snapshot['checked_at'];
$snapshotExtra = $snapshot['extra'];
$historyAvailable = true;
}
} else {
$counter = $this->counterFor($descriptor, $window);
if ($counter !== null) {
$used = $counter['used_quantity'];
$limit = $counter['limit_quantity'] ?? $limit;
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
$historyAvailable = true;
} else {
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
if ($derived !== null) {
$used = $derived;
$historyAvailable = true;
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
$used = 0.0;
$historyAvailable = true;
}
}
}
$status = $this->statusForUsage($used, $limit, $setting);
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
return array_merge([
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => (bool)$descriptor['writable_limit'],
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
'config_module' => $descriptor['config_module'] ?? null,
'config_variable' => $descriptor['config_variable'] ?? null,
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $usagePercent,
'status' => $status,
'enabled' => (bool)$setting['enabled'],
'enforce_mode' => $setting['enforce_mode'],
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
'window' => [
'start' => $window['start_c'],
'end' => $window['end_c'],
'timezone' => date_default_timezone_get(),
],
'updated_at' => $updatedAt,
'history_available' => $historyAvailable,
], $snapshotExtra);
}
private function requireDescriptor(string $moduleKey, string $metricKey): array
{
$descriptor = $this->registry->find($moduleKey, $metricKey);
if ($descriptor === null) {
throw new Exception('Unknown module usage metric.');
}
return $descriptor;
}
private function normalizeDescriptor(array $descriptor): array
{
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
return $found ?? $descriptor;
}
private function settingFor(array $descriptor): array
{
$default = [
'enabled' => true,
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
];
if (!$this->databaseReady()) {
return $default;
}
try {
global $db;
$result = $db->query(
"SELECT enabled, enforce_mode, soft_limit_percent
FROM module_quota_settings
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return $default;
}
return [
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
? (string)$row['enforce_mode']
: $default['enforce_mode'],
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
? (float)$row['soft_limit_percent']
: $default['soft_limit_percent'],
];
} catch (Throwable) {
return $default;
}
}
private function resolveLimitQuantity(array $descriptor): ?float
{
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT value
FROM module_config
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
return null;
}
return max(0.0, (float)$row['value']);
} catch (Throwable) {
return null;
}
}
private function writeConfigLimit(array $descriptor, int $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$module = (string)$descriptor['config_module'];
$variable = (string)$descriptor['config_variable'];
$type = (string)($descriptor['config_type'] ?? 'int');
$existing = $db->query(
"SELECT id
FROM module_config
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable) . "
LIMIT 1"
);
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
$db->query(
"UPDATE module_config
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable)
);
} else {
$db->query(
"INSERT INTO module_config (module, variable, value, type)
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
);
}
system_search_cache::markDirtyTable('module_config');
}
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
$metadata = [
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
];
$db->query(
"INSERT IGNORE INTO module_usage_counters
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
" . $this->sqlString((string)$descriptor['scope_type']) . ",
" . $this->sqlString((string)$descriptor['scope_id']) . ",
" . $this->sqlString($period['key']) . ",
" . $this->sqlString($period['start_sql']) . ",
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->numberSql($baseline) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
" . $this->jsonSql($metadata) . "
)"
);
}
private function counterFor(array $descriptor, array $period): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
FROM module_usage_counters
WHERE " . $this->counterWhereSql($descriptor, $period) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
return [
'used_quantity' => (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'status' => (string)$row['status'],
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
} catch (Throwable) {
return null;
}
}
private function latestProviderSnapshot(array $descriptor): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
FROM module_usage_snapshots
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
ORDER BY checked_at DESC, id DESC
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
$extra = [];
if (isset($usage['version'])) {
$extra['version'] = (string)$usage['version'];
}
if (array_key_exists('usage_available', $usage)) {
$extra['usage_available'] = $this->toBool($usage['usage_available']);
}
if (isset($usage['unavailable_reason'])) {
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
}
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
}
return [
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
'status' => (string)$row['status'],
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
'extra' => $extra,
];
} catch (Throwable) {
return null;
}
}
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
{
return [
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => false,
'limit_source' => 'provider',
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $percent,
'status' => $status,
'enabled' => true,
'enforce_mode' => 'observe',
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
'updated_at' => $checkedAt,
'history_available' => true,
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
? array_values(array_map('strval', $usage['detected_keys']))
: [],
];
}
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
{
try {
if (isset($descriptor['legacy_count_table'])) {
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
}
if (isset($descriptor['legacy_log_module'])) {
return $this->countActionLogs(
(string)$descriptor['legacy_log_module'],
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
$period
);
}
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
default => null,
};
} catch (Throwable) {
return null;
}
}
private function countActionLogs(string $module, ?string $action, array $period): ?float
{
if (!$this->tableExists('module_usage_logs')) {
return null;
}
global $db;
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
if ($action !== null && $action !== '') {
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
}
$where .= $this->periodWhereSql('created_at', $period);
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
{
if (!$this->tableExists($table)) {
return null;
}
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
return null;
}
global $db;
$where = '1=1';
if ($dateColumn !== null) {
$where .= $this->periodWhereSql($dateColumn, $period);
} elseif ($period['key'] !== 'all_time') {
return null;
}
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function sumColumn(string $table, string $column): ?float
{
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
return null;
}
global $db;
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_sum'] : null;
}
private function historyForModule(string $moduleKey, array $filters): array
{
if (!$this->databaseReady()) {
return [];
}
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
$rows = [];
try {
global $db;
$result = $db->query(
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
FROM module_usage_counters
WHERE module_key = " . $this->sqlString($moduleKey) . "
ORDER BY period_start DESC, id DESC
LIMIT {$limit}"
);
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
}
} catch (Throwable) {
return [];
}
return $rows;
}
private function statusForUsage(?float $used, ?float $limit, array $setting): string
{
if (($setting['enabled'] ?? true) !== true) {
return 'disabled';
}
if ($used === null) {
return 'unknown';
}
if ($limit === null) {
return 'unlimited';
}
if ($limit <= 0.0) {
return $used > 0.0 ? 'exhausted' : 'ok';
}
$percent = ($used / $limit) * 100;
if ($used >= $limit || $percent >= 100.0) {
return 'exhausted';
}
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
return 'near_limit';
}
return 'ok';
}
private function worseStatus(string $current, string $candidate): string
{
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
}
private function statusRank(string $status): int
{
return match ($status) {
'exhausted' => 5,
'near_limit' => 4,
'unknown' => 3,
'disabled' => 2,
'unlimited' => 1,
'ok' => 0,
default => 0,
};
}
private function quotaExceededMessage(array $descriptor): string
{
return match ((string)$descriptor['period']) {
'day' => 'Daily limit exceeded',
'month' => 'Monthly limit exceeded',
default => 'Quota limit exceeded',
};
}
private function periodWindow(string $period, ?string $date = null): array
{
$timestamp = $date ? strtotime($date) : time();
if ($timestamp === false) {
$timestamp = time();
}
return match ($period) {
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
};
}
private function periodFromTimestamps(string $key, int $start, int $end): array
{
return [
'key' => $key,
'start_sql' => date('Y-m-d H:i:s', $start),
'end_sql' => date('Y-m-d H:i:s', $end),
'start_c' => date('c', $start),
'end_c' => date('c', $end),
];
}
private function periodWhereSql(string $dateColumn, array $period): string
{
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
return '';
}
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
if ($dateColumn === '') {
return '';
}
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
}
private function counterWhereSql(array $descriptor, array $period): string
{
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
. " AND period_key = " . $this->sqlString($period['key'])
. " AND period_start = " . $this->sqlString($period['start_sql']);
}
private function databaseReady(): bool
{
global $db;
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
}
private function tableExists(string $table): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if ($table === '') {
return false;
}
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function columnExists(string $table, string $column): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return false;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function firstNumeric(array $payload, array $keys): ?float
{
foreach ($keys as $key) {
if (isset($payload[$key]) && is_numeric($payload[$key])) {
return (float)$payload[$key];
}
}
return null;
}
private function redactPayload(array $payload): array
{
$redacted = [];
foreach ($payload as $key => $value) {
$normalized = strtolower((string)$key);
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
}
return $redacted;
}
private function toBool(mixed $value): bool
{
return self::normalizeBoolean($value);
}
private function sqlString(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function numberSql(float $value): string
{
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
}
private function nullableNumberSql(?float $value): string
{
return $value === null ? 'NULL' : $this->numberSql($value);
}
private function jsonSql(array $value): string
{
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
+21 -3
View File
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
use motorapi\helpers\motorapi_vehicle_types;
use motorapi\motorapi_c;
use objects\motorapi_lookups_o;
use Throwable;
class motorapi implements motorapi_i
{
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
self::requireModuleEnabled();
// Validate the license plate
self::requireValidLicensePlate($licensePlate);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key
self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cache hits return before this point.
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
*/
function getDailyRequestCounter(): int
{
try {
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
} catch (Throwable) {
}
// Count the rows from the motorapi request log that was made today
$motorapi_lookups = new motorapi_lookups_o();
$motorapi_lookups->getTodayCount();
return $motorapi_lookups->getTodayCount();
}
/**
* @throws Exception
*/
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
'license_plate' => $licensePlate,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/**
* @inheritDoc
*/
@@ -8,7 +8,6 @@ class object_property
private string $table; // The id of the object in the database
private string $column; // The column name of the field in the database table (e.g. id, name, email)
private string $type; // The data type of the field in the database table (e.g. int, varchar, text)
private bool $required; // Whether the field is required or not
private mixed $default; // The default value of the field
private mixed $fake_value; // The fake value of the field, used for testing purposes (When the object id is -1)
@@ -18,7 +17,7 @@ class object_property
$this->id = $id;
$this->column = $column;
$this->type = $type;
$this->required = $required;
unset($required); // Retained in the constructor for compatibility with existing object definitions.
$this->default = $default;
}

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