Compare commits

...
Author SHA1 Message Date
Jeppe B 4430345831 fix(api): log new order id when POST /orders succeeds (#374)
Merges api PR #374.
2026-08-16 13:05:25 +02:00
5cde8103f9 fix(api): include wash_id in xlvask_missing_order_link flag text (AUT-49/TRU-49) (#375)
## Summary

The XL Vask missing-order-link flag previously rendered the literal
placeholder text `'XL Vask wash'` as the interactive link text.
`wash_id` was already present in the per-flag message context but was
not being threaded into the link text, so users couldn't tell which wash
the warning referred to.

This wires `wash_id` through both `messageParts()` (used to render the
clickable link) and `automaticMessage()` (the plain-text fallback) for
the `xlvask_missing_order_link` flag.

## Changes

`services/nginx/app/classes/invoice_period_flag_service.php`
- `messageParts()` `xlvask_missing_order_link` branch: split into three
parts so the `xlvask_usage_log` button text comes from
`$params['wash_id']`, with the literal "XL Vask wash " as a leading text
part and " is neither ignored nor linked to an order in the selected
period." as a trailing text part.
- `automaticMessage()` `xlvask_missing_order_link` branch: now
interpolates `wash_id` into the message string ("XL Vask wash {wash_id}
is neither ignored nor linked to an order in the selected period."),
with a sensible fallback to the original wording when `wash_id` is
missing.


`services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php`
- Updated the `message_parts` expectation in `it builds interactive
message parts for order and wash certificate warnings` to assert the new
three-part structure with the actual `wash-55` id.

## Verification

- `vendor/bin/pest tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php
--compact` → `Tests: 34 passed (183 assertions)`, exit 0
- `vendor/bin/pest tests/Unit/Invoicing/ --compact` → `Tests: 245 passed
(1 warning, 1 skipped)`, exit 0
- `vendor/bin/phpstan analyse classes/invoice_period_flag_service.php` →
`[OK] No errors`

## Issue

AUT-49 / TRU-49 — XL Vask missing-order-link flag text should reference
the actual wash (not 'XL Vask wash').

## Out of scope

- The Vue side at `copenhagentruckwash/pleno-vue` still has a hardcoded
fallback `"XL Vask wash"` for the i18n token
`invoice_period.flags.tokens.xlvask_usage_log`. That path is only used
when `flag.message_parts` is absent, which no longer happens for this
flag now that the backend populates it correctly. A follow-up on the Vue
side could remove that fallback or repurpose it as a tooltip label.
- No `Writerside2` topic covers `message_parts`, and `openapi.yaml` does
not formally document the field, so no spec update was required for this
content-only fix.

---

_This pull request was created by an AI agent (OpenHands) on behalf of
Jeppe B._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 22:18:18 +02:00
f4ba70623e feat(edge-broker): expose lastActivityAt on /api/health (AUT-2/TRU-6) (#373)
Adds the `lastActivityAt` field to the response body of the
`/api/health` endpoint exposed by the edge broker. The field reports
the most recent successful request timestamp from the container,
defaulting to the container's start time when no requests have been
served yet.

The field is also surfaced on `broker.state` (alongside a new
`containerStartedAt`) so callers can observe the activity timestamp
without performing an HTTP round-trip. The Writerside
`API-Reference.topic` and its generator are updated to document the
new field.

Resolves TRU-6 (AUT-2).

## Example request

```sh
curl -s http://edge-broker:8080/api/health
```

```json
{
  "ok": true,
  "service": "edge-broker",
  "auth_mode": "manager",
  "manager_url_configured": true,
  "shared_secret_configured": true,
  "agents_connected": 0,
  "lastActivityAt": "2026-08-15T19:15:34.898Z"
}
```

## Documentation

The diff for the writerside topic that documents the new field lives in
this PR — see
[`documentation/topics/API-Reference.topic`](https://github.com/copenhagentruckwash/api/blob/10f28d6/documentation/topics/API-Reference.topic)
(vs. [the previous version at
`origin/develop`](https://github.com/copenhagentruckwash/api/blob/cdf8541/documentation/topics/API-Reference.topic))
in the [PR "Files changed"
view](https://github.com/copenhagentruckwash/api/pull/373/files).
The same paragraph is reproduced by
`scripts/generate_writerside_openapi_docs.py` so future regenerations
preserve it.

**What consumers need to re-read.** `API-Reference.topic` adds a
paragraph
documenting the new `lastActivityAt` ISO 8601 timestamp on the edge
broker's `/api/health` response. Consumers that previously inferred
broker activity from indirect signals (e.g. comparing `agents_connected`
across polls or assuming a fresh process meant a fresh state) should now
read `lastActivityAt` directly: it is the timestamp of the most recent
successful HTTP request handled by the broker container, and defaults to
`containerStartedAt` until the first request lands. No request or
response shape changes; the field is purely additive.

## Tests

`node --test services/edge-broker/test/broker.test.mjs` covers both the
shape of the new field on `/api/health` and the fact that
`lastActivityAt`
advances on every successful request after `containerStartedAt`. All 21
broker tests pass locally.

---

_This PR description was generated by an OpenHands AI agent on behalf of
jepp9350._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 21:19:37 +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
347 changed files with 24302 additions and 5057 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 name: Qodana
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
branches:
- master
- beta
- canary
- internal
types:
- opened
- reopened
- synchronize
- ready_for_review
push: push:
branches: # Specify your branches here branches:
- main # The 'main' branch - master
- 'releases/*' # The release branches - beta
- canary
- internal
concurrency:
group: qodana-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
qodana: qodana:
# CI runs on the repository's self-hosted runner pool. name: Qodana
runs-on: [self-hosted, Linux, X64, pleno, backend, docker] 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: permissions:
contents: read contents: read
pull-requests: read checks: write
checks: read pull-requests: write
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Require Qodana Cloud token
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
env: env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
shell: bash
run: | run: |
if [ -n "${QODANA_TOKEN:-}" ]; then set -euo pipefail
echo "present=true" >> "$GITHUB_OUTPUT" if [[ -z "${QODANA_TOKEN}" ]]; then
else echo "::error::QODANA_TOKEN is not configured for this repository."
echo "present=false" >> "$GITHUB_OUTPUT" exit 1
fi fi
- name: 'Qodana Scan' - name: Check out the analyzed commit
if: ${{ steps.qodana-token.outputs.present == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: JetBrains/qodana-action@v2026.1
with: 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: env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} 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."
+84 -52
View File
@@ -3,39 +3,49 @@ name: Tests
on: on:
pull_request: pull_request:
push: 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: jobs:
php: php:
name: PHP ${{ matrix.suite }} (required) 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
suite: [unit, integration, api, legacy] suite: [unit, integration, api, legacy]
env: env:
DOCKER_HOST: unix:///var/run/docker.sock
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }} COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access - name: Ensure Docker access
run: | run: |
set -euo pipefail set -euo pipefail
if docker ps >/dev/null 2>&1; then docker ps >/dev/null 2>&1 || {
exit 0 echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
fi exit 1
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
- name: Setup Node.js - name: Setup Node.js
if: ${{ matrix.suite == 'unit' }} if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
@@ -49,7 +59,7 @@ jobs:
- name: Upload PHP suite logs - name: Upload PHP suite logs
if: ${{ failure() }} if: ${{ failure() }}
continue-on-error: true continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: php-${{ matrix.suite }}-logs name: php-${{ matrix.suite }}-logs
path: .tmp/ci-logs/${{ matrix.suite }} path: .tmp/ci-logs/${{ matrix.suite }}
@@ -58,14 +68,16 @@ jobs:
edge-agent: edge-agent:
name: Edge Agent (required) name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, pleno, backend] runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
@@ -103,25 +115,23 @@ jobs:
edge-broker: edge-broker:
name: Edge Broker (required) 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access - name: Ensure Docker access
run: | run: |
set -euo pipefail set -euo pipefail
if docker ps >/dev/null 2>&1; then docker ps >/dev/null 2>&1 || {
exit 0 echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
fi exit 1
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
- name: Materialize CI compose env files - name: Materialize CI compose env files
run: | run: |
@@ -135,7 +145,7 @@ jobs:
docker compose -f docker-compose.example.yml config > /dev/null docker compose -f docker-compose.example.yml config > /dev/null
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
@@ -149,8 +159,9 @@ jobs:
edge-gateway-backend: edge-gateway-backend:
name: Edge Gateway Backend (required) name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, pleno, backend, docker] runs-on: ubuntu-24.04
env: env:
DOCKER_HOST: unix:///var/run/docker.sock
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev COMPOSE_PROFILES: dev
@@ -163,21 +174,17 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Ensure Docker access - name: Ensure Docker access
run: | run: |
set -euo pipefail set -euo pipefail
if docker ps >/dev/null 2>&1; then docker ps >/dev/null 2>&1 || {
exit 0 echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
fi exit 1
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
- name: Allocate CI ports - name: Allocate CI ports
run: | run: |
@@ -228,7 +235,7 @@ jobs:
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
@@ -336,11 +343,42 @@ jobs:
if: always() if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v 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: release-manager-gate:
name: Release Manager gate name: Release Manager gate
runs-on: [self-hosted, Linux, X64, pleno, backend] runs-on: ubuntu-24.04
needs: [php, edge-agent, edge-broker, edge-gateway-backend] needs: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
steps: steps:
- name: Record Release Manager API gate - name: Record Release Manager API gate
@@ -368,12 +406,6 @@ jobs:
exit 0 exit 0
fi 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" printf '%s\n' "$response_body"
echo "Release Manager gate failed with HTTP $http_code." >&2 echo "Release Manager gate failed with HTTP $http_code." >&2
exit 1 exit 1
+1
View File
@@ -2,6 +2,7 @@
/docker-compose.yml /docker-compose.yml
/services/nginx/app/vendor/ /services/nginx/app/vendor/
/services/nginx/app/modules/washcertificates/vendor/ /services/nginx/app/modules/washcertificates/vendor/
/services/nginx/app/.phpunit.cache/
/services/nginx/letsencrypt/ /services/nginx/letsencrypt/
*.pem *.pem
*.log.gz *.log.gz
+5
View File
@@ -24,6 +24,7 @@ RUN set -eux; \
libzip-dev \ libzip-dev \
mariadb-client \ mariadb-client \
nginx \ nginx \
openssl \
pkg-config \ pkg-config \
redis-tools \ redis-tools \
unzip \ unzip \
@@ -46,6 +47,8 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/ /var/www/html/ 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/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/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 COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
@@ -61,6 +64,8 @@ RUN set -eux; \
fi; \ fi; \
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \ 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 -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; \ chown -R www-data:www-data /var/www/html; \
chmod -R 755 /var/www/html chmod -R 755 /var/www/html
+5
View File
@@ -2,6 +2,11 @@
Backend API for Copenhagen Truck Wash services. 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 ## Architecture & Stack
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting). - **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). - **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 'access_key' => '', // Minio access
'secret_key' => '' // Minio secret key '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 = [ $REDIS_CONFIG = [
'host' => '', // Redis host (IP address) 'host' => '', // Redis host (IP address)
'user' => '', // Redis user 'user' => '', // Redis user
+1 -1
View File
@@ -129,7 +129,7 @@ services:
- redis - redis
- mysql - mysql
- edge-broker - 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_file:
- .env.example - .env.example
environment: environment:
+1 -1
View File
@@ -367,7 +367,7 @@ services:
depends_on: depends_on:
- redis - redis
- edge-broker - 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_file:
- .env - .env
environment: environment:
+1 -1
View File
@@ -425,7 +425,7 @@ services:
depends_on: depends_on:
- redis - redis
- edge-broker - 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_file:
- .env - .env
environment: 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" "Orders"
], ],
"summary": "Create Stripe payment intent", "summary": "Create Stripe payment intent",
"description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.",
"operationId": "createStripePaymentIntent", "operationId": "createStripePaymentIntent",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -9873,9 +9874,6 @@
}, },
"reader": { "reader": {
"type": "string" "type": "string"
},
"tax_percentage": {
"type": "integer"
} }
} }
} }
+1
View File
@@ -7,4 +7,5 @@
<!-- AUTO-GENERATED, DO NOT EDIT --> <!-- AUTO-GENERATED, DO NOT EDIT -->
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p> <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> </topic>
@@ -12,7 +12,7 @@
</chapter> </chapter>
<chapter title="Operation" id="operation"> <chapter title="Operation" id="operation">
<p>Operation ID: <code>createStripePaymentIntent</code></p> <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>
<chapter title="Authentication" id="authentication"> <chapter title="Authentication" id="authentication">
<p>Security requirements:</p> <p>Security requirements:</p>
@@ -32,9 +32,6 @@
}, },
&quot;reader&quot;: { &quot;reader&quot;: {
&quot;type&quot;: &quot;string&quot; &quot;type&quot;: &quot;string&quot;
},
&quot;tax_percentage&quot;: {
&quot;type&quot;: &quot;integer&quot;
} }
}, },
&quot;required&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.
+532 -16
View File
@@ -1746,9 +1746,9 @@ paths:
- Subusers - Subusers
summary: Create a subuser registration summary: Create a subuser registration
description: | description: |
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the Starts a driver setup challenge using a company's CVR and a phone number. No company grant
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled is created until the driver proves possession of the phone by completing the SMS setup link.
sends a setup link by SMS for the user to complete registration. The public response is uniform and never includes setup credentials or relationship state.
operationId: createSubuser operationId: createSubuser
security: [] security: []
requestBody: requestBody:
@@ -1761,6 +1761,7 @@ paths:
- cvr - cvr
- phone_country_code - phone_country_code
- phone - phone
- g_recaptcha_response
properties: properties:
cvr: cvr:
type: integer type: integer
@@ -1774,24 +1775,23 @@ paths:
type: integer type: integer
description: Phone number (415 digits, no leading +) description: Phone number (415 digits, no leading +)
example: 12345678 example: 12345678
g_recaptcha_response:
type: string
description: reCAPTCHA response token
responses: responses:
'200': '200':
description: Subuser created (or pending setup) and company identified description: Uniform driver registration acknowledgement
content: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: properties:
cvr: message:
type: integer type: string
example: 12345678
customer_number:
type: integer
description: Matched e-conomic customer number
example: 1000
'400': { $ref: '#/components/responses/BadRequest' } '400': { $ref: '#/components/responses/BadRequest' }
'404': { $ref: '#/components/responses/NotFound' } '404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
/subusers/{id}: /subusers/{id}:
get: get:
@@ -2245,6 +2245,44 @@ paths:
'500': { $ref: '#/components/responses/InternalServerError' } '500': { $ref: '#/components/responses/InternalServerError' }
# Authentication Endpoints # Authentication Endpoints
/auth/limited-backoffice-login-grants/exchange:
post:
tags:
- Authentication
summary: Exchange a one-time limited-backoffice employee login grant
description: Exchanges an unexpired, unrevoked grant exactly once for a regular employee bearer session. The grant is invalidated atomically before the session is returned.
operationId: exchangeLimitedBackofficeEmployeeLoginGrant
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [grant]
properties:
grant:
type: string
pattern: '^lbg_[a-f0-9]{64}$'
writeOnly: true
responses:
'200':
description: Grant exchanged
content:
application/json:
schema:
type: object
required: [employee_id, token]
properties:
employee_id: {type: integer}
token:
type: string
description: Sensitive bearer token returned once by a successful grant exchange.
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/auth/login: /auth/login:
post: post:
tags: tags:
@@ -2579,6 +2617,156 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
/account/deletion:
get:
tags:
- Security
summary: Describe account deletion requirements
description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations.
operationId: getAccountDeletion
responses:
'200':
description: Account deletion requirements retrieved successfully
content:
application/json:
schema:
type: object
required:
- principal_type
- status
- confirmation_phrase
- password_required
- two_factor_required
- access_effect
- retained_data_categories
- privacy_policy_version
properties:
principal_type:
type: string
enum: [customer, subuser]
status:
type: string
enum: [available, requested, processing, failed, manual_review, completed]
confirmation_phrase:
type: string
enum: [SLET MIN KONTO]
password_required:
type: boolean
description: False for authenticated passkey-only accounts that have no password.
two_factor_required:
type: boolean
access_effect:
type: string
retained_data_categories:
type: array
items:
type: string
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
privacy_policy_version:
type: string
request_id:
type: string
format: uuid
nullable: true
requested_at:
type: string
format: date-time
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
post:
tags:
- Security
summary: Request deletion of the authenticated account
description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history.
operationId: requestAccountDeletion
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- confirmation
- acknowledge_legal_retention
properties:
password:
type: string
format: password
description: Required when password_required is true; omit for passkey-only accounts.
passkey_challenge_token:
type: string
description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint.
passkey_credential:
type: object
description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal.
two_factor_code:
type: string
description: Required when two-factor authentication is enabled.
confirmation:
type: string
enum: [SLET MIN KONTO]
acknowledge_legal_retention:
type: boolean
enum: [true]
responses:
'202':
description: Deletion request accepted and account access revoked
content:
application/json:
schema:
type: object
required:
- request_id
- status
- requested_at
- access_revoked
- retained_data_categories
properties:
request_id:
type: string
format: uuid
status:
type: string
enum: [requested, processing, failed, manual_review, completed]
requested_at:
type: string
format: date-time
access_revoked:
type: boolean
enum: [true]
retained_data_categories:
type: array
items:
type: string
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: Too many deletion confirmation attempts
'500':
$ref: '#/components/responses/InternalServerError'
/account/deletion/passkey/challenge:
post:
tags: [Security]
summary: Create a deletion-specific WebAuthn challenge
description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion.
operationId: createAccountDeletionPasskeyChallenge
responses:
'200':
description: Deletion-specific challenge created
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/auth/2fa/setup: /auth/2fa/setup:
post: post:
tags: tags:
@@ -2970,6 +3158,107 @@ paths:
properties: properties:
token: {type: string} token: {type: string}
/limited-backoffice/employees/{employeeId}/login-grants:
post:
tags:
- Limited Backoffice
summary: Create or preflight a one-time employee login grant
description: Requires limited-backoffice employee-management permissions and access to every department assigned to the employee. The bearer is deterministically derived under the server encryption key so an identical idempotent retry can recover the same unconsumed grant after a lost response; only its digest is stored.
operationId: createLimitedBackofficeEmployeeLoginGrant
parameters:
- name: employeeId
in: path
required: true
schema: {type: integer, minimum: 1}
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
purpose:
type: string
enum: [limited_backoffice_employee_login]
default: limited_backoffice_employee_login
ttl_seconds:
type: integer
minimum: 60
maximum: 900
default: 300
idempotency_key:
type: string
minLength: 16
maxLength: 128
writeOnly: true
preflight:
type: boolean
default: false
oneOf:
- required: [idempotency_key]
properties:
preflight:
type: boolean
enum: [false]
- required: [preflight]
properties:
preflight:
type: boolean
enum: [true]
responses:
'200':
description: Grant created, safely replayed for the same idempotency key, or request validated in preflight mode
content:
application/json:
schema:
type: object
required: [employee_id, purpose, ttl_seconds, expires_at, one_time, preflight]
properties:
employee_id: {type: integer}
purpose: {type: string}
ttl_seconds: {type: integer}
expires_at: {type: string, format: date-time}
one_time: {type: boolean}
preflight: {type: boolean}
grant_id: {type: string, pattern: '^[a-f0-9]{32}$'}
login_path:
type: string
description: Sensitive fragment URL returned only for a newly created or safely replayed grant.
exchange_path: {type: string}
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
delete:
tags:
- Limited Backoffice
summary: Revoke active one-time employee login grants
operationId: revokeLimitedBackofficeEmployeeLoginGrants
parameters:
- name: employeeId
in: path
required: true
schema: {type: integer, minimum: 1}
responses:
'200':
description: Active grants revoked
content:
application/json:
schema:
type: object
required: [employee_id, revoked_count]
properties:
employee_id: {type: integer}
revoked_count: {type: integer, minimum: 0}
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/limited-backoffice/employees/{employeeId}/login-link: /limited-backoffice/employees/{employeeId}/login-link:
post: post:
tags: tags:
@@ -4007,6 +4296,19 @@ paths:
properties: properties:
enabled: enabled:
type: boolean type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
put: put:
@@ -4039,6 +4341,21 @@ paths:
properties: properties:
message: message:
type: string type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
@@ -6442,6 +6759,14 @@ paths:
responses: responses:
'200': '200':
description: Success description: Success
'400':
description: Invalid booking input or a product blocked by active customer rules
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
- type: object
put: put:
tags: tags:
- Bookings - Bookings
@@ -8289,6 +8614,7 @@ paths:
tags: tags:
- Orders - Orders
summary: Create Stripe payment intent summary: Create Stripe payment intent
description: Creates a Stripe Terminal card payment intent with fixed 25% moms.
operationId: createStripePaymentIntent operationId: createStripePaymentIntent
requestBody: requestBody:
required: true required: true
@@ -8300,7 +8626,6 @@ paths:
properties: properties:
id: {type: integer} id: {type: integer}
reader: {type: string} reader: {type: string}
tax_percentage: {type: integer}
responses: responses:
'200': '200':
description: Success description: Success
@@ -13218,8 +13543,72 @@ paths:
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/prices:
get:
tags:
- Limited Backoffice
summary: Get explicit limited-backoffice department prices
description: Returns only explicit department prices and an opaque revision for optimistic concurrency.
operationId: getLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Explicit department prices and current revision
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409': '409':
$ref: '#/components/responses/Conflict' $ref: '#/components/responses/Conflict'
put:
tags:
- Limited Backoffice
summary: Replace explicit limited-backoffice department prices
description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest'
responses:
'200':
description: Explicit department prices updated atomically
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/customer-pricing: /limited-backoffice/departments/{departmentId}/customer-pricing:
get: get:
@@ -13268,7 +13657,7 @@ paths:
tags: tags:
- Limited Backoffice - Limited Backoffice
summary: Replace limited-backoffice department customer pricing summary: Replace limited-backoffice department customer pricing
description: Replaces the complete override set for one customer in an assigned custom-only department. description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentCustomerPricing operationId: setLimitedBackofficeDepartmentCustomerPricing
parameters: parameters:
- name: departmentId - name: departmentId
@@ -13297,7 +13686,7 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409': '409':
$ref: '#/components/responses/Conflict' $ref: '#/components/responses/PricingConflict'
/superuser/department/variables: /superuser/department/variables:
get: get:
@@ -14072,6 +14461,14 @@ components:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
PricingConflict:
description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Error'
- $ref: '#/components/schemas/PricingRevisionConflictResponse'
Unauthorized: Unauthorized:
description: Unauthorized - Invalid or missing authentication token description: Unauthorized - Invalid or missing authentication token
content: content:
@@ -14265,6 +14662,8 @@ components:
customer_number: customer_number:
type: integer type: integer
minimum: 1 minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides: overrides:
type: array type: array
items: items:
@@ -14281,6 +14680,8 @@ components:
customer_number: customer_number:
type: integer type: integer
minimum: 1 minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides: overrides:
type: array type: array
items: items:
@@ -14311,6 +14712,7 @@ components:
type: integer type: integer
nullable: true nullable: true
minimum: 0 minimum: 0
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
DepartmentCustomerPricingOverride: DepartmentCustomerPricingOverride:
allOf: allOf:
@@ -14397,6 +14799,8 @@ components:
type: integer type: integer
display_name: display_name:
type: string type: string
revision:
$ref: '#/components/schemas/PricingRevision'
overrides: overrides:
type: array type: array
items: items:
@@ -14408,6 +14812,95 @@ components:
meta: meta:
type: object type: object
additionalProperties: true additionalProperties: true
PricingRevision:
type: string
pattern: '^[a-f0-9]{64}$'
description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update.
PricingRevisionConflictResponse:
type: object
required: [success, data]
properties:
success:
type: boolean
enum: [false]
data:
type: object
required: [message, code, current_revision]
properties:
message:
type: string
enum: [Pricing has changed. Reload and try again.]
code:
type: string
enum: [pricing_revision_conflict]
current_revision:
$ref: '#/components/schemas/PricingRevision'
meta:
type: object
additionalProperties: true
LimitedBackofficeDepartmentPriceInput:
type: object
required: [product_id, price]
properties:
product_id:
type: integer
minimum: 1
price:
type: integer
minimum: 0
LimitedBackofficeDepartmentPricesUpdateRequest:
type: object
required: [prices]
properties:
expected_revision:
$ref: '#/components/schemas/PricingRevision'
prices:
type: array
minItems: 1
items:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput'
LimitedBackofficeDepartmentPricesResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
department:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
custom_pricing_only: { type: boolean }
revision:
$ref: '#/components/schemas/PricingRevision'
categories:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
products:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
price: { type: integer }
meta:
type: object
additionalProperties: true
includes: includes:
type: object type: object
additionalProperties: true additionalProperties: true
@@ -18055,11 +18548,13 @@ components:
additionalProperties: additionalProperties:
type: array type: array
items: items:
$ref: '#/components/schemas/InvoicingPeriodCustomer' oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
InvoicingPeriodCustomer: InvoicingPeriodCustomer:
type: object type: object
required: [customer_number, customer_name, transactions, invoice_collections] required: [customer_number]
additionalProperties: true additionalProperties: true
properties: properties:
customer_number: customer_number:
@@ -18075,6 +18570,27 @@ components:
items: items:
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection' $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view
bucket of the period response. Used by the front-end to render
category indicator chips (e.g. "Faktura pr. ordre") regardless of
which tab the user is currently looking at. Full customer-card
data (transactions, invoice collections, queue, draft, meta)
is intentionally omitted for non-active buckets; see
InvoicingPeriodCustomer for the shape returned for the active
bucket.
additionalProperties: false
required: [customer_number, membership_only]
properties:
customer_number:
type: integer
minimum: 1
membership_only:
type: boolean
enum: [true]
InvoicingPeriodTransaction: InvoicingPeriodTransaction:
type: object type: object
required: [id, booked, invoice_state] required: [id, booked, invoice_state]
+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" version: "1.0"
#Specify inspection profile for code analysis linter: jetbrains/qodana-php:2026.1
profile: profile:
name: qodana.starter name: qodana.recommended
#Enable inspections php:
#include: version: "8.2"
# - name: <SomeEnabledInspectionId>
#Disable inspections bootstrap: |+
#exclude: set -eu
# - name: <SomeDisabledInspectionId> composer --working-dir=services/nginx/app install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
# paths: composer --working-dir=services/nginx/app/modules/washcertificates install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
# - <path/where/not/run/inspection> 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) exclude:
#bootstrap: sh ./prepare-qodana.sh # 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.
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) - name: PhpIllegalPsrClassPathInspection
#plugins: paths:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com) - services/nginx/app
# Unit-test doubles intentionally bypass integration-heavy parent constructors.
# Quality gate. Will fail the CI/CD pipeline if any condition is not met - name: PhpMissingParentConstructorInspection
# severityThresholds - configures maximum thresholds for different problem severities paths:
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code - services/nginx/app/tests
# Code Coverage is available in Ultimate and Ultimate Plus plans # These focused tests configure doubles through public fields before invoking behavior.
#failureConditions: - name: PhpObjectFieldsAreOnlyWrittenInspection
# severityThresholds: paths:
# any: 15 - services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php
# critical: 5 - services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php
# testCoverageThresholds: - services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php
# fresh: 70 # API coverage markers are intentional statement-style calls in the Pest DSL.
# total: 50 # Their return value is irrelevant; the call records route/scenario coverage.
- name: PhpExpressionResultUnusedInspection
#Specify Qodana linter for analysis (Applied in CI/CD pipeline) paths:
linter: jetbrains/qodana-php:2025.3 - 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; raw += chunk;
}); });
response.on("end", () => { response.on("end", () => {
let decoded = {}; let decoded;
try { try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw); decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch { } catch {
+6 -9
View File
@@ -95,7 +95,7 @@ function directCaddyBaseUrl(baseUrl) {
} }
function isLocalHost(hostname) { 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"; return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
} }
@@ -227,11 +227,7 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
return true; return true;
} }
if (/already exists|already connected/i.test(stderr)) { return /already exists|already connected/i.test(stderr);
return true;
}
return false;
} }
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) { async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
@@ -833,7 +829,7 @@ async function main() {
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." } { timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
); );
} catch (error) { } catch (error) {
let operationSnapshot = null; let operationSnapshot;
try { try {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, { const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken, token: authToken,
@@ -959,9 +955,10 @@ async function main() {
allowFailure: true, allowFailure: true,
}).catch(() => {}); }).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) { const fixtureAuthToken = fixture?.auth_token;
if (gatewayId !== null && fixtureAuthToken) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, { await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token), token: String(fixtureAuthToken),
}).catch(() => {}); }).catch(() => {});
} }
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
' title="API Reference" id="API-Reference">\n' ' title="API Reference" id="API-Reference">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n" f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\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" "</topic>\n"
) )
+7
View File
@@ -146,6 +146,13 @@ tar \
-cf - \ -cf - \
Dockerfile \ Dockerfile \
Dockerfile.coolify-api \ 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/Dockerfile \
services/php/php-fpm-pool.conf \ services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf - | docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
+3 -3
View File
@@ -4,9 +4,9 @@
"requires": true, "requires": true,
"packages": { "packages": {
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+37
View File
@@ -40,6 +40,10 @@ class Receiver extends Writable {
* extensions * extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in * @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode * 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 {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages * 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._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {}; this._extensions = options.extensions || {};
this._isServer = !!options.isServer; this._isServer = !!options.isServer;
this._maxBufferedChunks = options.maxBufferedChunks | 0;
this._maxFragments = options.maxFragments | 0;
this._maxPayload = options.maxPayload | 0; this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation; this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined; this[kWebSocket] = undefined;
@@ -71,6 +77,7 @@ class Receiver extends Writable {
this._totalPayloadLength = 0; this._totalPayloadLength = 0;
this._messageLength = 0; this._messageLength = 0;
this._numFragments = 0;
this._fragments = []; this._fragments = [];
this._errored = false; this._errored = false;
@@ -89,6 +96,22 @@ class Receiver extends Writable {
_write(chunk, encoding, cb) { _write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return 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._bufferedBytes += chunk.length;
this._buffers.push(chunk); this._buffers.push(chunk);
this.startLoop(cb); this.startLoop(cb);
@@ -478,6 +501,19 @@ class Receiver extends Writable {
return; 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) { if (this._compressed) {
this._state = INFLATING; this._state = INFLATING;
this.decompress(data, cb); this.decompress(data, cb);
@@ -550,6 +586,7 @@ class Receiver extends Writable {
this._totalPayloadLength = 0; this._totalPayloadLength = 0;
this._messageLength = 0; this._messageLength = 0;
this._fragmented = 0; this._fragmented = 0;
this._numFragments = 0;
this._fragments = []; this._fragments = [];
if (this._opcode === 2) { if (this._opcode === 2) {
+6 -1
View File
@@ -4,6 +4,9 @@
const { Duplex } = require('stream'); const { Duplex } = require('stream');
const { randomFillSync } = require('crypto'); const { randomFillSync } = require('crypto');
const {
types: { isUint8Array }
} = require('util');
const PerMessageDeflate = require('./permessage-deflate'); const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants'); const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
@@ -200,8 +203,10 @@ class Sender {
if (typeof data === 'string') { if (typeof data === 'string') {
buf.write(data, 2); buf.write(data, 2);
} else { } else if (isUint8Array(data)) {
buf.set(data, 2); 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 * called
* @param {Function} [options.handleProtocols] A hook to handle protocols * @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server * @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 * @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size * size
* @param {Boolean} [options.noServer=false] Enable no server mode * @param {Boolean} [options.noServer=false] Enable no server mode
@@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter {
options = { options = {
allowSynchronousEvents: true, allowSynchronousEvents: true,
autoPong: true, autoPong: true,
maxBufferedChunks: 256 * 1024,
maxFragments: 16 * 1024,
maxPayload: 100 * 1024 * 1024, maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false, skipUTF8Validation: false,
perMessageDeflate: false, perMessageDeflate: false,
@@ -424,6 +430,8 @@ class WebSocketServer extends EventEmitter {
ws.setSocket(socket, head, { ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents, allowSynchronousEvents: this.options.allowSynchronousEvents,
maxBufferedChunks: this.options.maxBufferedChunks,
maxFragments: this.options.maxFragments,
maxPayload: this.options.maxPayload, maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation skipUTF8Validation: this.options.skipUTF8Validation
}); });
+14
View File
@@ -201,6 +201,10 @@ class WebSocket extends EventEmitter {
* multiple times in the same tick * multiple times in the same tick
* @param {Function} [options.generateMask] The function used to generate the * @param {Function} [options.generateMask] The function used to generate the
* masking key * 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 {Number} [options.maxPayload=0] The maximum allowed message size
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages * not to skip UTF-8 validation for text and close messages
@@ -212,6 +216,8 @@ class WebSocket extends EventEmitter {
binaryType: this.binaryType, binaryType: this.binaryType,
extensions: this._extensions, extensions: this._extensions,
isServer: this._isServer, isServer: this._isServer,
maxBufferedChunks: options.maxBufferedChunks,
maxFragments: options.maxFragments,
maxPayload: options.maxPayload, maxPayload: options.maxPayload,
skipUTF8Validation: options.skipUTF8Validation skipUTF8Validation: options.skipUTF8Validation
}); });
@@ -640,6 +646,10 @@ module.exports = WebSocket;
* masking key * masking key
* @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
* handshake request * 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 * @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size * size
* @param {Number} [options.maxRedirects=10] The maximum number of redirects * @param {Number} [options.maxRedirects=10] The maximum number of redirects
@@ -660,6 +670,8 @@ function initAsClient(websocket, address, protocols, options) {
autoPong: true, autoPong: true,
closeTimeout: CLOSE_TIMEOUT, closeTimeout: CLOSE_TIMEOUT,
protocolVersion: protocolVersions[1], protocolVersion: protocolVersions[1],
maxBufferedChunks: 256 * 1024,
maxFragments: 16 * 1024,
maxPayload: 100 * 1024 * 1024, maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false, skipUTF8Validation: false,
perMessageDeflate: true, perMessageDeflate: true,
@@ -1017,6 +1029,8 @@ function initAsClient(websocket, address, protocols, options) {
websocket.setSocket(socket, head, { websocket.setSocket(socket, head, {
allowSynchronousEvents: opts.allowSynchronousEvents, allowSynchronousEvents: opts.allowSynchronousEvents,
generateMask: opts.generateMask, generateMask: opts.generateMask,
maxBufferedChunks: opts.maxBufferedChunks,
maxFragments: opts.maxFragments,
maxPayload: opts.maxPayload, maxPayload: opts.maxPayload,
skipUTF8Validation: opts.skipUTF8Validation skipUTF8Validation: opts.skipUTF8Validation
}); });
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ws", "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", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [ "keywords": [
"HyBi", "HyBi",
@@ -66,5 +66,9 @@
"nyc": "^15.0.0", "nyc": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"utf-8-validate": "^6.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", "name": "truckwash-edge-broker",
"dependencies": { "dependencies": {
"ws": "^8.18.0" "ws": "^8.21.1"
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+1 -1
View File
@@ -7,6 +7,6 @@
"test:live": "node --test live/live-smoke.mjs" "test:live": "node --test live/live-smoke.mjs"
}, },
"dependencies": { "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 || ""); 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) { if (options.authMode) {
return options.authMode; return options.authMode;
} }
@@ -179,7 +179,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
export function createBrokerServer(options = {}) { export function createBrokerServer(options = {}) {
const sharedSecret = resolveSharedSecret(options); const sharedSecret = resolveSharedSecret(options);
const managerUrl = resolveManagerUrl(options); const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl); const authMode = resolveAuthMode(options);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000; const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS; const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
const browserStreamSessions = new Map(); const browserStreamSessions = new Map();
const gatewayStreamSessions = new Map(); const gatewayStreamSessions = new Map();
const inflightGatewaySyncs = new Map(); const inflightGatewaySyncs = new Map();
const containerStartedAt = currentTimestamp();
let lastActivityAt = containerStartedAt;
const managerRequest = async (path, body = {}, method = "POST") => { const managerRequest = async (path, body = {}, method = "POST") => {
if (!managerUrl) { if (!managerUrl) {
@@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) {
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
const url = new URL(req.url, "http://localhost"); const url = new URL(req.url, "http://localhost");
lastActivityAt = currentTimestamp();
if (req.method === "GET" && url.pathname === "/api/health") { if (req.method === "GET" && url.pathname === "/api/health") {
jsonResponse(res, 200, { jsonResponse(res, 200, {
ok: true, ok: true,
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
manager_url_configured: Boolean(managerUrl), manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret), shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size, agents_connected: agents.size,
lastActivityAt,
}); });
return; return;
} }
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
pendingCommands, pendingCommands,
managerUrl, managerUrl,
authMode, 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.auth_mode, "manager");
assert.equal(healthJson.manager_url_configured, true); assert.equal(healthJson.manager_url_configured, true);
assert.equal(healthJson.shared_secret_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`, { const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST", method: "POST",
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
await broker.close(); 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 () => { test("broker bridges browser shell sessions through the connected agent", async () => {
const closedSessions = []; const closedSessions = [];
const broker = createBrokerServer({ 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", () => { test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker"); const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); 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_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); 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-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.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`\)/); 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", () => { test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker"); const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); 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_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); 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-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\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/); 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", () => { test("standalone production compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker"); const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); 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_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-manager\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); 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-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.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`\)/); 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", () => { test("compose config does not provide insecure broker secret defaults", () => {
for (const composeSource of [baseComposeSource, exampleComposeSource]) { 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_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/); 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"]) { for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName); const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/); 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_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/); 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
+43 -4
View File
@@ -2,6 +2,8 @@
namespace classes; namespace classes;
require_once WD . '/classes/account_deletion_service.php';
use classes\totp; use classes\totp;
use Exception; use Exception;
use interfaces\authentication_i; use interfaces\authentication_i;
@@ -69,6 +71,10 @@ class authentication implements authentication_i
public function create_2fa_token(int $id, string $type): string 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 // Create a temporary 2FA token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
(new tokens_o())->create($id, $token, $type); (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); throw new \Exception('User not found for customer number: ' . $customer_number);
} }
$user_id = $user->id; $user_id = $user->id;
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
throw new Exception('Account unavailable');
}
// Save the token in the database // Save the token in the database
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN'); (new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
return $token; return $token;
@@ -107,6 +116,9 @@ class authentication implements authentication_i
public function create_token_by_user_id(int $user_id): string 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 // Create a token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
// Save the token in the database // Save the token in the database
@@ -116,6 +128,9 @@ class authentication implements authentication_i
public function create_employee_token(int $employee_id): string 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 // Create a token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
// Save the token in the database // Save the token in the database
@@ -123,13 +138,26 @@ class authentication implements authentication_i
return $token; 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 public function validate_token(string $token): bool
{ {
// First: try validating as a classic user auth token // First: try validating as a classic user auth token
try { try {
$dbToken = (new tokens_o())->getToken($token); $dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') { if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
return true; return !account_deletion_service::principalIsBlocked(
'customer',
(int)$dbToken->user_id->value()
);
} }
} catch (Exception) { } catch (Exception) {
// Ignore and continue to subuser session validation // Ignore and continue to subuser session validation
@@ -137,7 +165,7 @@ class authentication implements authentication_i
// Fallback: try validating as a subuser session token // Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token); $subuser = (new subusers_o())->getSubuserBySessionToken($token);
if ($subuser !== null) { if ($subuser !== null) {
return true; return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
} }
return false; return false;
} }
@@ -168,7 +196,10 @@ class authentication implements authentication_i
if (!$token->id) { if (!$token->id) {
return false; 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; return false;
} }
// Get the user from the database // Get the user from the database
@@ -177,6 +208,11 @@ class authentication implements authentication_i
return $user; 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 public function get_plate_scanner(): plate_scanners_o|false
{ {
// Get the token from the headers // Get the token from the headers
@@ -227,6 +263,9 @@ class authentication implements authentication_i
if ($subuser === null) { if ($subuser === null) {
return false; return false;
} }
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
return false;
}
$customerNumberContext = null; $customerNumberContext = null;
if (isset($headers['X-Customer-Number'])) { if (isset($headers['X-Customer-Number'])) {
$customerNumberContext = (int)$headers['X-Customer-Number']; $customerNumberContext = (int)$headers['X-Customer-Number'];
-13
View File
@@ -212,19 +212,7 @@ class bird implements bird_i
throw new Exception('cURL error: ' . $err); throw new Exception('cURL error: ' . $err);
} }
curl_close($ch); curl_close($ch);
// Debug slack
$data = json_decode($body, true) ?? [];
$resp = $resp === false ? 'cURL error with no response' : $resp; $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 [ return [
'status_code' => (int)$code, 'status_code' => (int)$code,
'body' => $resp, 'body' => $resp,
@@ -1073,4 +1061,3 @@ class bird implements bird_i
} }
} }
+67 -7
View File
@@ -5,6 +5,8 @@ namespace classes;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
require_once __DIR__ . '/cors_policy.php';
class coolify_manager class coolify_manager
{ {
private const KINDS = ['database', 'redis', 'minio']; private const KINDS = ['database', 'redis', 'minio'];
@@ -1070,7 +1072,9 @@ class coolify_manager
$targetPublicUrl, $targetPublicUrl,
$resourceUuid, $resourceUuid,
self::resourceFirstExposedPort($resource, $target), self::resourceFirstExposedPort($resource, $target),
$resource['custom_labels'] ?? null $resource['custom_labels'] ?? null,
$app,
self::gatewayRouteTargetCorsConfig($target)
); );
$update = $resourceType === 'service' $update = $resourceType === 'service'
? $client->updateService($resourceUuid, $updatePayload) ? $client->updateService($resourceUuid, $updatePayload)
@@ -2420,7 +2424,9 @@ class coolify_manager
string $publicUrl, string $publicUrl,
string $resourceUuid = '', string $resourceUuid = '',
?int $port = null, ?int $port = null,
mixed $existingLabels = null mixed $existingLabels = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$decodedLabels = self::decodeCoolifyLabels($existingLabels); $decodedLabels = self::decodeCoolifyLabels($existingLabels);
@@ -2435,7 +2441,9 @@ class coolify_manager
$publicUrl, $publicUrl,
$resourceUuid, $resourceUuid,
$routePort, $routePort,
self::gatewayRouteDefaultCertResolver($publicUrl) self::gatewayRouteDefaultCertResolver($publicUrl),
$app,
$corsConfig
); );
if ($labels !== []) { if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( $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 private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
{ {
if ($port === null || $port <= 0) { if ($port === null || $port <= 0) {
@@ -2483,7 +2534,9 @@ class coolify_manager
string $publicUrl, string $publicUrl,
string $resourceUuid, string $resourceUuid,
?int $port = null, ?int $port = null,
?string $certResolver = null ?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$resourceUuid = self::gatewayRouteLabelId($resourceUuid); $resourceUuid = self::gatewayRouteLabelId($resourceUuid);
@@ -2508,6 +2561,7 @@ class coolify_manager
$certResolver = trim((string)($certResolver ?? '')); $certResolver = trim((string)($certResolver ?? ''));
$httpLabel = 'http-0-' . $resourceUuid; $httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid;
$isApi = strtolower(trim($app)) === 'api';
$labels = [ $labels = [
'traefik.enable=true', 'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true', 'traefik.http.middlewares.gzip.compress=true',
@@ -2521,12 +2575,18 @@ class coolify_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; $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 !== '/') { if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; $httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
} }
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') { if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
+60 -5
View File
@@ -29,6 +29,7 @@ class cors_policy
'http://localhost:5174', 'http://localhost:5174',
'http://127.0.0.1:5173', 'http://127.0.0.1:5173',
'http://127.0.0.1:5174', 'http://127.0.0.1:5174',
'capacitor://localhost',
]; ];
public static function normalizeOrigin(?string $value): string public static function normalizeOrigin(?string $value): string
@@ -38,7 +39,7 @@ class cors_policy
return $value; return $value;
} }
if (preg_match('#^https?://#i', $value) !== 1) { if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
return ''; return '';
} }
@@ -48,7 +49,7 @@ class cors_policy
} }
$scheme = strtolower((string)$parts['scheme']); $scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) { if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
return ''; return '';
} }
@@ -58,6 +59,27 @@ class cors_policy
return $scheme . '://' . $host . $port; 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> * @return array<int,string>
*/ */
@@ -66,6 +88,39 @@ class cors_policy
return self::REQUIRED_ALLOWED_ORIGINS; 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> * @return array<int,string>
*/ */
@@ -105,8 +160,8 @@ class cors_policy
public static function isOriginAllowed(?string $origin, string $corsConfig): bool public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{ {
$origin = self::normalizeOrigin($origin); $origin = self::normalizeRequestOrigin($origin);
if ($origin === '' || $origin === '*') { if ($origin === '') {
return false; return false;
} }
@@ -119,7 +174,7 @@ class cors_policy
*/ */
public static function responseHeaders(?string $origin, string $corsConfig): array public static function responseHeaders(?string $origin, string $corsConfig): array
{ {
$origin = self::normalizeOrigin($origin); $origin = self::normalizeRequestOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) { if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return []; return [];
} }
+27 -6
View File
@@ -227,10 +227,15 @@ class cron_scheduler
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id) "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
); );
return $this->executeClaimedRun($definition, $run_id, $started); return $this->executeClaimedRun($definition, $run_id, $started, $scheduled_for);
} }
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array private function executeClaimedRun(
cron_task_definition $definition,
int $run_id,
float $started,
?string $scheduled_for = null
): array
{ {
$status = 'succeeded'; $status = 'succeeded';
$summary = []; $summary = [];
@@ -272,7 +277,7 @@ class cron_scheduler
$completed_at = date('Y-m-d H:i:s', (int)$completed); $completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message); $this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at); $this->releaseLock($definition, $status, $error_message, $completed_at, $scheduled_for);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []); return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
} }
@@ -441,7 +446,12 @@ class cron_scheduler
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id) "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
); );
return $this->executeClaimedRun($definition, $run_id, $started); 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 private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
@@ -479,7 +489,13 @@ class cron_scheduler
); );
} }
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void 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)); $state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null); $schedule = $this->decodeJson($state['schedule_json'] ?? null);
@@ -487,7 +503,12 @@ class cron_scheduler
$schedule = $definition->schedule; $schedule = $definition->schedule;
} }
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time()); // 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') { if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds'])); $retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds); $nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
@@ -84,6 +84,8 @@ class cron_schema_bootstrap
last_heartbeat_at DATETIME NULL, last_heartbeat_at DATETIME NULL,
last_loop_started_at DATETIME NULL, last_loop_started_at DATETIME NULL,
last_loop_finished_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, stopped_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
@@ -93,6 +95,8 @@ class cron_schema_bootstrap
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid) KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ) 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; self::$initialized = true;
} }
+50 -11
View File
@@ -3,9 +3,14 @@
namespace classes; namespace classes;
use Throwable; use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class cron_worker class cron_worker
{ {
use boolean_normalization_t;
private cron_scheduler $scheduler; private cron_scheduler $scheduler;
private string $worker_id; private string $worker_id;
private string $name; private string $name;
@@ -39,6 +44,7 @@ class cron_worker
$this->heartbeat('starting', 0, 0, null, true); $this->heartbeat('starting', 0, 0, null, true);
while (!$this->should_stop) { while (!$this->should_stop) {
$pollStarted = microtime(true);
$result = $this->tick(); $result = $this->tick();
$this->writeStatusLine($result); $this->writeStatusLine($result);
@@ -47,7 +53,7 @@ class cron_worker
break; break;
} }
$this->sleepUntilNextPoll(); $this->sleepUntilNextPoll($pollStarted + $this->poll_seconds);
} }
$this->heartbeat('stopped', 0, 0, null, true, true); $this->heartbeat('stopped', 0, 0, null, true, true);
@@ -119,13 +125,14 @@ class cron_worker
}); });
} }
private function sleepUntilNextPoll(): void private function sleepUntilNextPoll(float $nextPollAt): void
{ {
$remaining = $this->poll_seconds; while (!$this->should_stop) {
while ($remaining > 0 && !$this->should_stop) { $remaining = $nextPollAt - microtime(true);
$sleep = min(1, $remaining); if ($remaining <= 0) {
sleep($sleep); return;
$remaining -= $sleep; }
usleep((int)(min(1.0, $remaining) * 1000000));
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) { if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
$this->heartbeat('running'); $this->heartbeat('running');
} }
@@ -162,18 +169,19 @@ class cron_worker
$errorSql = $this->nullableSql($error); $errorSql = $this->nullableSql($error);
$loopStarted = $this->nullableSql($loopStartedAt); $loopStarted = $this->nullableSql($loopStartedAt);
$stoppedAt = $stopped ? $this->sql($now) : 'NULL'; $stoppedAt = $stopped ? $this->sql($now) : 'NULL';
$nowSql = $this->sql($now);
$this->query( $this->query(
"INSERT INTO cron_worker_state ( "INSERT INTO cron_worker_state (
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id, 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, 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_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
last_loop_finished_at, stopped_at last_loop_finished_at, last_loop_gap_seconds, consecutive_minute_loops, stopped_at
) VALUES ( ) VALUES (
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId, $workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount, $resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted, $staleRunCount, $errorSql, $nowSql, $nowSql, $loopStarted,
$this->sql($now), $stoppedAt $nowSql, NULL, " . ($loopStartedAt !== null ? '1' : '0') . ", $stoppedAt
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name = VALUES(name), name = VALUES(name),
@@ -191,6 +199,17 @@ class cron_worker
last_stale_run_count = VALUES(last_stale_run_count), last_stale_run_count = VALUES(last_stale_run_count),
last_error = VALUES(last_error), last_error = VALUES(last_error),
last_heartbeat_at = VALUES(last_heartbeat_at), 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_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
last_loop_finished_at = VALUES(last_loop_finished_at), last_loop_finished_at = VALUES(last_loop_finished_at),
stopped_at = VALUES(stopped_at)" stopped_at = VALUES(stopped_at)"
@@ -203,6 +222,17 @@ class cron_worker
$heartbeatTs = strtotime($heartbeatAt); $heartbeatTs = strtotime($heartbeatAt);
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30); $threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null; $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 [ return [
'worker_id' => (string)($row['worker_id'] ?? ''), 'worker_id' => (string)($row['worker_id'] ?? ''),
@@ -225,6 +255,15 @@ class cron_worker
'last_heartbeat_age_seconds' => $age, 'last_heartbeat_age_seconds' => $age,
'last_loop_started_at' => $row['last_loop_started_at'] ?? null, 'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
'last_loop_finished_at' => $row['last_loop_finished_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, 'stopped_at' => $row['stopped_at'] ?? null,
'stale' => $age === null || $age > $threshold, 'stale' => $age === null || $age > $threshold,
'stale_after_seconds' => $threshold, 'stale_after_seconds' => $threshold,
@@ -257,7 +296,7 @@ class cron_worker
return $default; return $default;
} }
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); return self::normalizeBoolean($value);
} }
private function commitSha(): string private function commitSha(): string
@@ -396,7 +396,7 @@ class customer_rule_product_restriction_service
return $ids; return $ids;
} }
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */ /** @return list<array{id: ?int, name: string, sort_order: int, product_ids: list<int>}> */
private function validateCollections(string $attribute, mixed $value): array private function validateCollections(string $attribute, mixed $value): array
{ {
if (!is_array($value)) { if (!is_array($value)) {
@@ -25,6 +25,7 @@ class department_customer_pricing_service
'customer' => $customer, 'customer' => $customer,
'overrides' => $overrides, 'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']), 'categories' => $this->catalog($departmentId, $customer['id']),
'revision' => $this->revision($overrides),
]; ];
} }
@@ -52,34 +53,69 @@ class department_customer_pricing_service
$normalized = $this->normalizeOverrides($departmentId, $overrides); $normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o(); $overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']); $expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
$existingOverrides = [];
$normalizedKeys = []; $normalizedKeys = [];
foreach ($normalized as $override) { foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true; $normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
} }
global $db; global $db;
$db->conn()->begin_transaction(); $mysqli = $db->conn();
$mysqli->begin_transaction();
try { try {
$db->query( $this->lockDepartment($departmentId);
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ' $existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id'] $currentRevision = $this->revision($existingOverrides);
); if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
throw $this->revisionConflict($currentRevision);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
} }
$db->conn()->commit(); $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) { } catch (\Throwable) {
$db->conn()->rollback(); $mysqli->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500); throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
} }
@@ -254,6 +290,9 @@ class department_customer_pricing_service
} }
if ($isCategory) { if ($isCategory) {
if ($fixedPrice !== null) {
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
}
$fixedPrice = null; $fixedPrice = null;
$objectId = (string)$objectId; $objectId = (string)$objectId;
if ($objectId !== 'global') { if ($objectId !== 'global') {
@@ -269,6 +308,14 @@ class department_customer_pricing_service
} }
$key = $this->overrideKey($isCategory, $objectId); $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] = [ $normalized[$key] = [
'is_category' => $isCategory, 'is_category' => $isCategory,
'product_or_category_id' => $objectId, 'product_or_category_id' => $objectId,
@@ -313,6 +360,64 @@ class department_customer_pricing_service
return ((int)$isCategory) . ':' . (string)$objectId; 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 private function assertDepartmentProduct(int $departmentId, int $productId): void
{ {
global $db; global $db;
@@ -302,10 +302,10 @@ class department_outside_hours_statistics_service
* @param array<int,array<string,mixed>> $opening_hours_by_department_id * @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<string,array<int,bool>>|null $missing_lookup_by_day * @param array<string,array<int,bool>>|null $missing_lookup_by_day
* @return array{ * @return array{
* counted:bool, * counted: bool,
* reason:string, * reason: string,
* candidate_date:?string, * candidate_date: ?string,
* department_id:int * department_id: int
* } * }
*/ */
public function classifyCandidateAgainstOpeningHours( public function classifyCandidateAgainstOpeningHours(
@@ -328,23 +328,21 @@ class economic_transfer_executor
$economic_dimension_id = $department['economic_dimension_id'] ?? 0; $economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$order_item_price = (float)($order_item['price'] ?? 0); $order_item_price = (float)($order_item['price'] ?? 0);
$product_price = (float)($order_item['product']['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( $economic_invoice_draft->addLine(
$product_number, $product_number,
$product_name, $product_name,
$quantity, $quantity,
$order_item_price, $order_item_price,
0, $discount_percentage,
(int)$economic_department_id ?? 0, (int)$economic_department_id ?? 0,
(int)$economic_dimension_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 !== '') { if ($reference !== '') {
$economic_invoice_draft->addLineTEXT('Reference:'); $economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($reference, "\n")) { if (str_contains($reference, "\n")) {
@@ -40,9 +40,14 @@ class economic_transfer_queue
$max_attempts = max(1, min(10, $max_attempts)); $max_attempts = max(1, min(10, $max_attempts));
$transfer_type = $this->validateTransferType($transfer_type); $transfer_type = $this->validateTransferType($transfer_type);
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by); $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); $active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
if ($active_job !== null) { if ($active_job !== null) {
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
$target_label = $this->buildTargetLabel($transfer_type, $payload); $target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent( $this->logQueueEvent(
1, 1,
@@ -75,6 +80,8 @@ class economic_transfer_queue
$job_id = (int)$db->insert_id(); $job_id = (int)$db->insert_id();
$stmt->close(); $stmt->close();
$this->registerJobRequester($job_id, $created_by);
$this->logQueueEvent( $this->logQueueEvent(
1, 1,
$created_by, $created_by,
@@ -145,12 +152,19 @@ class economic_transfer_queue
return null; 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) { if (!$stmt) {
return null; 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()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
return null; return null;
@@ -179,7 +193,8 @@ class economic_transfer_queue
$offset = max(0, $offset); $offset = max(0, $offset);
$where = $this->buildListJobsWhereClause($statuses, $transfer_type); $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"; $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql); $result = $db->query($sql);
if (!$result instanceof mysqli_result) { if (!$result instanceof mysqli_result) {
@@ -203,7 +218,8 @@ class economic_transfer_queue
} }
$where = $this->buildListJobsWhereClause($statuses, $transfer_type); $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"; $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
$result = $db->query($sql); $result = $db->query($sql);
if (!$result instanceof mysqli_result) { if (!$result instanceof mysqli_result) {
@@ -242,6 +258,9 @@ class economic_transfer_queue
global $db; global $db;
$user_id = max(0, $user_id); $user_id = max(0, $user_id);
if ($user_id < 1) {
return [];
}
$limit = max(1, min(100, $limit)); $limit = max(1, min(100, $limit));
try { try {
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
@@ -262,7 +281,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status AND d.dismissed_status = q.status
WHERE q.created_by = $user_id WHERE " . $this->jobVisibilitySql('q', $user_id) . "
$transfer_condition $transfer_condition
AND ( AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
@@ -355,7 +374,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status 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 . "') AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
$transfer_condition $transfer_condition
AND d.queue_job_id IS NULL AND d.queue_job_id IS NULL
@@ -389,6 +408,9 @@ class economic_transfer_queue
if ($existing_job === null) { if ($existing_job === null) {
throw new Exception('Queue job not found'); 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) { if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
throw new Exception('Only failed jobs can be retried'); throw new Exception('Only failed jobs can be retried');
} }
@@ -610,11 +632,53 @@ class economic_transfer_queue
if ($collected_invoice_id < 1) { if ($collected_invoice_id < 1) {
throw new Exception('collected_invoice_id is required'); 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); $send_as_is = (bool)($payload['send_as_is'] ?? false);
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice'); $this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by); 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 private function updateProgress(int $job_id, int $percent, string $message): void
{ {
global $db; 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"); $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. * 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']); $normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
} }
return match ($transfer_type) { if ($transfer_type === self::TYPE_COLLECTED_INVOICE_EXPORT) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by), return $this->normalizeCollectedInvoicePayload($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 (!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; $order_id = $payload['order_id'] ?? null;
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) { 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; $payload['order_id'] = (int)$order_id;
return $payload; return $payload;
@@ -783,7 +882,7 @@ class economic_transfer_queue
{ {
$collected_invoice_id = $payload['collected_invoice_id'] ?? null; $collected_invoice_id = $payload['collected_invoice_id'] ?? null;
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) { 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; $payload['collected_invoice_id'] = (int)$collected_invoice_id;
@@ -804,17 +903,17 @@ class economic_transfer_queue
if ($numeric === 0 || $numeric === 1) { if ($numeric === 0 || $numeric === 1) {
return $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)) { if (is_string($value)) {
$normalized = strtolower(trim($value)); $normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'false', '1', '0'], true)) { if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
return in_array($normalized, ['true', '1'], 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 private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
@@ -840,8 +939,8 @@ class economic_transfer_queue
{ {
global $db; global $db;
$created_by = max(0, $created_by); // Active work is unique by transfer type and business target across all requesting users.
if ($target_value < 1 || $created_by < 1) { if ($target_value < 1) {
return null; return null;
} }
@@ -851,7 +950,6 @@ class economic_transfer_queue
WHERE transfer_type = ? WHERE transfer_type = ?
AND status IN (?, ?) AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
AND created_by = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT 1" LIMIT 1"
); );
@@ -861,7 +959,7 @@ class economic_transfer_queue
$queued = self::STATUS_QUEUED; $queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING; $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()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
return null; return null;
@@ -54,6 +54,17 @@ class economic_transfer_queue_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ) 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; self::$initialized = true;
} }
} }
@@ -10,7 +10,7 @@ class economic_v2_revenue_statistics_service
private economic $economic; 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 = []; private array $customer_cache = [];
public function __construct(?economic $economic = null) public function __construct(?economic $economic = null)
@@ -44,7 +44,6 @@ class economic_v2_revenue_statistics_service
$summary = [ $summary = [
'invoice_count' => 0, 'invoice_count' => 0,
'line_count' => 0, 'line_count' => 0,
'unique_customers' => 0,
'net_amount' => 0.0, 'net_amount' => 0.0,
'vat_amount' => 0.0, 'vat_amount' => 0.0,
'gross_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 private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
{ {
@@ -500,4 +499,3 @@ class economic_v2_revenue_statistics_service
return $data; return $data;
} }
} }
+91 -11
View File
@@ -34,6 +34,7 @@ use MailerSend\Helpers\Builder\Recipient;
use MailerSend\MailerSend; use MailerSend\MailerSend;
use objects\bookings_o; use objects\bookings_o;
use objects\departments_o; use objects\departments_o;
use objects\logs_o;
use objects\users_o; use objects\users_o;
use Psr\Http\Client\ClientExceptionInterface; 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 the email is blacklisted, return without sending the email
if (in_array($to, $blacklisted_emails)) { 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; return;
} }
// Send POST request to email service // Send POST request to email service
@@ -164,12 +174,19 @@ use Psr\Http\Client\ClientExceptionInterface;
if ($attachments) { if ($attachments) {
$attachments = array_map(function ($attachment) { $attachments = array_map(function ($attachment) {
// Read the data from the path (Attachment[0]) and set the filename (Attachment[1]) // Read the data from the path (Attachment[0]) and set the filename (Attachment[1])
$attachment[0] = file_get_contents($attachment[0]); $path = (string)$attachment[0];
if ($attachment[0] === false) { $contents = file_get_contents($path);
throw new Exception('Failed to read file: ' . $attachment[0]); 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])) { 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]); return new Attachment($attachment[0], $attachment[1]);
}, $attachments); }, $attachments);
@@ -489,7 +506,13 @@ use Psr\Http\Client\ClientExceptionInterface;
{ {
// Validate the booking object // Validate the booking object
$order_booking->requireSelected(); $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 // Get the order details
$order = $order_booking->getOrder(); $order = $order_booking->getOrder();
// Get customer details // Get customer details
@@ -604,6 +627,14 @@ use Psr\Http\Client\ClientExceptionInterface;
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) { foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
$recipientEmail = trim((string)($recipient['email'] ?? '')); $recipientEmail = trim((string)($recipient['email'] ?? ''));
if ($recipientEmail === '') { 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; continue;
} }
@@ -612,12 +643,61 @@ use Psr\Http\Client\ClientExceptionInterface;
$recipientName = $recipientEmail; $recipientName = $recipientEmail;
} }
$this->sendEmail( // Per-recipient try/catch so a single bad MailerSend response does
$recipientEmail, // not break delivery to the remaining superuser recipients - this
$recipientName, // loop is unprotected upstream and a transient 5xx would otherwise
'New customer registered on Truck Wash', // mean the rest of the team silently stops hearing about new
$message, // 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 public function encrypt(string $data): string
{ {
// Debug:
return $data; 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 public function decrypt(string $data): string
{ {
// Debug:
return $data; 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'; require_once WD . '/modules/entra/entra_c.php';
use entra\entra_c; use entra\entra_c;
use Microsoft\Graph\GraphServiceClient;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContextBuilder;
class entra class entra
@@ -23,42 +20,95 @@ class entra
$this->config = new entra_c(); $this->config = new entra_c();
} }
public function get_users($array = false): array|object public function get_users(bool $array = false): array
{ {
$graphClient = $this->getGraphClient(); $accessToken = $this->requestAccessToken();
$usersResponse = $this->requestJson(
$users = $graphClient->users() 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,userPrincipalName',
->get() ['Authorization: Bearer ' . $accessToken]
->wait() );
->getValue(); $users = is_array($usersResponse['value'] ?? null) ? $usersResponse['value'] : [];
if (!$array) { if (!$array) {
return $users; return $users;
} }
$result = []; $result = [];
foreach ( $users as $user ) { foreach ($users as $user) {
if (!is_array($user)) {
continue;
}
$result[] = [ $result[] = [
'id' => $user->getId(), 'id' => $user['id'] ?? null,
'displayName' => $user->getDisplayName(), 'displayName' => $user['displayName'] ?? null,
'mail' => $user->getMail(), 'mail' => $user['mail'] ?? null,
'userPrincipalName' => $user->getUserPrincipalName(), 'userPrincipalName' => $user['userPrincipalName'] ?? null,
]; ];
} }
return $result; return $result;
} }
public function getGraphClient(): GraphServiceClient private function requestAccessToken(): string
{ {
return new GraphServiceClient( $tenantId = trim((string)$this->config->tenant_id->getVariableValue());
$this->getTokenRequestContext(), $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( $curl = curl_init($url);
$this->config->tenant_id->getVariableValue(), if ($curl === false) {
$this->config->client_id->getVariableValue(), throw new \RuntimeException('Unable to initialize Microsoft Entra request.');
$this->config->client_secret->getVariableValue() }
);
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;
} }
} }
-1
View File
@@ -10,7 +10,6 @@ require_once WD . '/modules/forms/form_helper_c.php';
use Exception; use Exception;
use forms\form_helper_c; use forms\form_helper_c;
use forms\objects\book_interior_wash_f;
use forms\objects\book_wash_f; use forms\objects\book_wash_f;
use objects\form_submissions_o; use objects\form_submissions_o;
use traits\form_t; use traits\form_t;
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(); 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; self::$initialized = true;
} }
@@ -76,8 +76,11 @@ class invoice_period_flag_service
$this->nullableIntSql($userId > 0 ? $userId : null) $this->nullableIntSql($userId > 0 ? $userId : null)
); );
$db->query($sql); $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 public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array
@@ -107,6 +110,8 @@ class invoice_period_flag_service
); );
$db->query($sql); $db->query($sql);
$this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($id); return $this->getStoredFlag($id);
} }
@@ -227,6 +232,61 @@ class invoice_period_flag_service
return $types; 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 private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
{ {
$transactionIds = []; $transactionIds = [];
@@ -331,6 +391,12 @@ class invoice_period_flag_service
} }
} }
private function refreshManualFlagsCacheAfterMutation(): void
{
$this->manualFlagsInstanceCache = null;
$this->warmManualFlagsCache();
}
private function fetchActiveManualFlagsFromDb(): array private function fetchActiveManualFlagsFromDb(): array
{ {
global $db; global $db;
@@ -1231,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) { foreach ($primaryRows as $row) {
$reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); $reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
if ($reg === '' || !isset($history[$reg])) { if ($reg === '' || !isset($history[$reg])) {
@@ -1402,6 +1495,7 @@ class invoice_period_flag_service
{ {
$product = (string)($params['product'] ?? 'Item'); $product = (string)($params['product'] ?? 'Item');
$expectedProduct = (string)($params['expected_product'] ?? 'expected product'); $expectedProduct = (string)($params['expected_product'] ?? 'expected product');
$washId = (string)($params['wash_id'] ?? '');
return match ($definitionKey) { return match ($definitionKey) {
'price_mismatch' => "{$product} product price differs from expected.", 'price_mismatch' => "{$product} product price differs from expected.",
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.", 'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
@@ -1421,7 +1515,9 @@ class invoice_period_flag_service
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.", '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}.", '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}.", '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.", default => "Automatically detected invoice-period issue.",
}; };
} }
@@ -1448,7 +1544,8 @@ class invoice_period_flag_service
['type' => 'text', 'text' => ' is attached without a wash certificate item.'], ['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
], ],
'xlvask_missing_order_link' => [ '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.'], ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
], ],
default => [], default => [],
@@ -1708,7 +1805,7 @@ class invoice_period_flag_service
return $map; return $map;
} }
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
{ {
global $db; global $db;
@@ -1729,6 +1826,16 @@ class invoice_period_flag_service
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string { $registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
return "'" . $db->escape_string($registrationNumber) . "'"; return "'" . $db->escape_string($registrationNumber) . "'";
}, array_keys($registrations))); }, 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( $result = $db->query(
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count "SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
FROM orders o FROM orders o
@@ -1742,6 +1849,7 @@ class invoice_period_flag_service
AND COALESCE(oi.related_item_id, 0) = 0 AND COALESCE(oi.related_item_id, 0) = 0
AND COALESCE(o.reg_1, '') <> '' AND COALESCE(o.reg_1, '') <> ''
AND o.reg_1 IN ({$registrationFilter}) AND o.reg_1 IN ({$registrationFilter})
{$reg2Filter}
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
ORDER BY reg, usage_count DESC, oi.product_id ASC" ORDER BY reg, usage_count DESC, oi.product_id ASC"
); );
@@ -137,7 +137,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key); $cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
if ($cached_result !== null) { if ($cached_result !== null) {
$this->last_timings['cache_hit'] = 1; $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); $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result; return $this->completeRecognition($started_at, $recognized_result);
} }
$recognized_result = [ $recognized_result = [
@@ -211,12 +211,19 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
]; ];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result); $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result; return $this->completeRecognition($started_at, $recognized_result);
} finally { } catch (\Throwable $exception) {
$this->last_timings['total'] = $this->elapsedMs($started_at); $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 private static function clientDisconnectAbortCallback(): callable
{ {
return static function (): int { return static function (): int {
@@ -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_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`) KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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); SQL);
self::$initialized = true; self::$initialized = true;
@@ -628,12 +628,47 @@ class limited_backoffice_service
} }
if ($user->hasPermission('superuser')) { if ($user->hasPermission('superuser')) {
global $db; return $this->allDepartmentIds();
$rows = $db->fetch_all($db->query( }
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows)); return $this->accessibleDepartmentIdsForGroup($groupId);
}
/**
* Returns the authoritative department scope only when the authenticated
* user is managed by limited backoffice. A null result means the caller
* must preserve the route's existing non-limited authorization semantics.
*
* @return array<int, int>|null
*/
public function managedEmployeeDepartmentIds(users_o $user): ?array
{
if (!$user->exists() || (int)$user->id <= 0) {
return null;
}
$managedEmployee = $this->loadManagedEmployee((int)$user->id);
if ($managedEmployee === null) {
return null;
}
return $this->decodeDepartmentIds((string)($managedEmployee['department_ids'] ?? '[]'));
}
/**
* Loads department scope from an authoritative group identity rather than
* from user object properties that may be backed by a stale Redis value.
*
* @return array<int, int>
*/
public function accessibleDepartmentIdsForGroup(int $groupId): array
{
if ($groupId <= 0) {
return [];
}
if ($groupId === 1) {
return $this->allDepartmentIds();
} }
global $db; global $db;
@@ -653,6 +688,9 @@ class limited_backoffice_service
$departmentIds = []; $departmentIds = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$permission = (string)($row['permission'] ?? ''); $permission = (string)($row['permission'] ?? '');
if ($permission === 'superuser') {
return $this->allDepartmentIds();
}
if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) { if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) {
continue; continue;
} }
@@ -666,6 +704,19 @@ class limited_backoffice_service
return array_values(array_unique($departmentIds)); return array_values(array_unique($departmentIds));
} }
/**
* @return array<int, int>
*/
private function allDepartmentIds(): array
{
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -723,6 +774,7 @@ class limited_backoffice_service
return [ return [
'department' => $department, 'department' => $department,
'categories' => $catalog['categories'], 'categories' => $catalog['categories'],
'revision' => $this->departmentPricesRevision($departmentId),
]; ];
} }
@@ -774,10 +826,17 @@ class limited_backoffice_service
} }
} }
$expectedRevision = $this->normalizeExpectedPricingRevision($payload['expected_revision'] ?? null);
$mysqli = $this->mysqli(); $mysqli = $this->mysqli();
$mysqli->begin_transaction(); $mysqli->begin_transaction();
try { try {
$this->lockDepartmentForPricingUpdate($departmentId);
$currentRevision = $this->departmentPricesRevision($departmentId);
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
throw $this->pricingRevisionConflict($currentRevision);
}
$deleteStatement = $mysqli->prepare( $deleteStatement = $mysqli->prepare(
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?' 'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
); );
@@ -790,15 +849,22 @@ class limited_backoffice_service
foreach ($normalizedPrices as $productId => $price) { foreach ($normalizedPrices as $productId => $price) {
$deleteStatement->bind_param('ii', $departmentId, $productId); $deleteStatement->bind_param('ii', $departmentId, $productId);
$deleteStatement->execute(); if (!$deleteStatement->execute()) {
throw new \RuntimeException('Unable to clear department price.');
}
$insertStatement->bind_param('iii', $departmentId, $productId, $price); $insertStatement->bind_param('iii', $departmentId, $productId, $price);
$insertStatement->execute(); if (!$insertStatement->execute()) {
throw new \RuntimeException('Unable to save department price.');
}
} }
$deleteStatement->close(); $deleteStatement->close();
$insertStatement->close(); $insertStatement->close();
$mysqli->commit(); $mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
$mysqli->rollback(); $mysqli->rollback();
throw new limited_backoffice_exception('Unable to update department prices.', 500); throw new limited_backoffice_exception('Unable to update department prices.', 500);
@@ -899,11 +965,65 @@ class limited_backoffice_service
$password = $this->normalizePassword($payload['password'] ?? null, true); $password = $this->normalizePassword($payload['password'] ?? null, true);
$email = $this->normalizeEmail($payload['email'] ?? null, true); $email = $this->normalizeEmail($payload['email'] ?? null, true);
$phone = $this->normalizeOptionalPhonePair($payload); $phone = $this->normalizeOptionalPhonePair($payload);
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
if ($idempotencyKey !== '' && (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128)) {
throw new limited_backoffice_exception(
'idempotency_key must contain between 16 and 128 characters.',
400
);
}
$idempotencyKeyHash = $idempotencyKey === '' ? null : hash('sha256', $idempotencyKey);
$payloadJson = (string)json_encode([
'department_ids' => $departmentIds,
'role_key' => $roleKey,
'display_name' => $displayName,
'password' => $password,
'email' => $email,
'phone_country_code' => $phone['phone_country_code'],
'phone' => $phone['phone'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$payloadHash = $idempotencyKeyHash === null
? null
: hash_hmac('sha256', $payloadJson, $this->idempotencyDigestKey());
$mysqli = $this->mysqli(); $mysqli = $this->mysqli();
$mysqli->begin_transaction(); $mysqli->begin_transaction();
try { try {
if ($idempotencyKeyHash !== null) {
$replayedEmployeeId = $this->reserveEmployeeCreateIdempotency(
(int)$manager->id,
$idempotencyKeyHash,
$payloadHash
);
if ($replayedEmployeeId !== null) {
$employee = $this->loadManagedEmployee($replayedEmployeeId);
if ($employee === null) {
throw new limited_backoffice_exception(
'Idempotent employee result is unavailable.',
409
);
}
$currentDepartmentIds = $this->decodeDepartmentIds(
(string)$employee['department_ids']
);
$this->assertDepartmentSubset($manager, $currentDepartmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception(
'Idempotent employee result is no longer active.',
409
);
}
$mysqli->commit();
return $this->formatEmployee(
$employee,
$currentDepartmentIds,
$this->isEmployeeRowActive($employee)
);
}
}
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER; $customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT); $passwordHash = password_hash($password, PASSWORD_DEFAULT);
@@ -950,7 +1070,35 @@ class limited_backoffice_service
$statement->execute(); $statement->execute();
$statement->close(); $statement->close();
if ($idempotencyKeyHash !== null) {
$statement = $mysqli->prepare(
'UPDATE `limited_backoffice_action_idempotency`
SET `result_user_id` = ?
WHERE `actor_user_id` = ?
AND `action_type` = ?
AND `idempotency_key_hash` = ?
LIMIT 1'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency result.');
}
$managerId = (int)$manager->id;
$actionType = 'employee.create';
$statement->bind_param(
'iiss',
$employeeId,
$managerId,
$actionType,
$idempotencyKeyHash
);
$statement->execute();
$statement->close();
}
$mysqli->commit(); $mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) { } catch (\Throwable) {
$mysqli->rollback(); $mysqli->rollback();
throw new limited_backoffice_exception('Unable to create employee.', 500); throw new limited_backoffice_exception('Unable to create employee.', 500);
@@ -964,6 +1112,66 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true); return $this->formatEmployee($employee, $departmentIds, true);
} }
private function reserveEmployeeCreateIdempotency(
int $actorUserId,
string $keyHash,
string $payloadHash
): ?int {
$mysqli = $this->mysqli();
$actionType = 'employee.create';
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`, `payload_hash`)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE `id` = `id`'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency reservation.');
}
$statement->bind_param('isss', $actorUserId, $actionType, $keyHash, $payloadHash);
$statement->execute();
$statement->close();
$statement = $mysqli->prepare(
'SELECT `payload_hash`, `result_user_id`
FROM `limited_backoffice_action_idempotency`
WHERE `actor_user_id` = ?
AND `action_type` = ?
AND `idempotency_key_hash` = ?
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency lookup.');
}
$statement->bind_param('iss', $actorUserId, $actionType, $keyHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if ($row === null) {
throw new \RuntimeException('Unable to load employee idempotency reservation.');
}
if (!hash_equals((string)$row['payload_hash'], $payloadHash)) {
throw new limited_backoffice_exception(
'Idempotency key was already used with a different employee payload.',
409
);
}
return $row['result_user_id'] === null ? null : (int)$row['result_user_id'];
}
private function idempotencyDigestKey(): string
{
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
if ($key === '') {
throw new limited_backoffice_exception(
'Employee idempotency protection is not configured.',
500
);
}
return hash_hmac('sha256', 'limited-backoffice-employee-idempotency-v1', $key, true);
}
/** /**
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
* @return array<string, mixed> * @return array<string, mixed>
@@ -1082,6 +1290,25 @@ class limited_backoffice_service
$mysqli->begin_transaction(); $mysqli->begin_transaction();
try { try {
$lock = $mysqli->prepare(
'SELECT lbe.`user_id`
FROM `limited_backoffice_employees` lbe
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
WHERE lbe.`user_id` = ? AND lbe.`managed_group_id` = ?
LIMIT 1
FOR UPDATE'
);
if ($lock === false) {
throw new \RuntimeException('Unable to prepare employee update lock.');
}
$lock->bind_param('ii', $employeeId, $managedGroupId);
$lock->execute();
$lockedEmployee = $lock->get_result()->fetch_assoc() ?: null;
$lock->close();
if ($lockedEmployee === null) {
throw new limited_backoffice_exception('Managed employee changed before update.', 409);
}
if ($active) { if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds)); $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
} }
@@ -1157,9 +1384,13 @@ class limited_backoffice_service
} }
/** /**
* @return array{employee_id:int,login_path:string} * Applies the target and department boundary used by one-time login grants.
*/ */
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array public function assertEmployeeLoginTarget(
users_o $manager,
int $employeeId,
?int $authoritativeGroupId = null
): void
{ {
$this->assertNotSelfEdit($manager, $employeeId); $this->assertNotSelfEdit($manager, $employeeId);
@@ -1169,32 +1400,12 @@ class limited_backoffice_service
} }
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']); $departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds); $this->assertDepartmentSubset($manager, $departmentIds, $authoritativeGroupId);
$this->assertManagedTargetIsSafe($employee); $this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) { if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409); throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
} }
$token = (new authentication())->create_employee_token($employeeId);
try {
(new logs_o())->add(
'auth',
'global',
1,
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
'Created limited backoffice login link for employee: ' . $employeeId
);
} catch (\Throwable) {
// Audit logging should not block login-link generation.
}
return [
'employee_id' => $employeeId,
'login_path' => '/login/qr?token=' . $token,
];
} }
private function mysqli(): mysqli private function mysqli(): mysqli
@@ -1203,6 +1414,80 @@ class limited_backoffice_service
return $db->conn(); return $db->conn();
} }
private function lockDepartmentForPricingUpdate(int $departmentId): void
{
$statement = $this->mysqli()->prepare(
'SELECT `id` FROM `departments` WHERE `id` = ? FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare pricing update lock.');
}
$statement->bind_param('i', $departmentId);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
throw new limited_backoffice_exception('Department not found', 404);
}
}
private function departmentPricesRevision(int $departmentId): string
{
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `product_id`, `price`
FROM `product_department_prices`
WHERE `department_id` = ?
ORDER BY `product_id` ASC, `id` ASC'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare department price revision.');
}
$statement->bind_param('i', $departmentId);
$statement->execute();
$rows = $db->fetch_all($statement->get_result());
$statement->close();
$revisionRows = array_map(static fn(array $row): array => [
'product_id' => (int)$row['product_id'],
'price' => (int)$row['price'],
], $rows);
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
}
private function normalizeExpectedPricingRevision(mixed $value): ?string
{
if ($value === null || $value === '') {
// Transitional compatibility for already-deployed clients. New clients
// send the revision returned by GET and receive stale-write protection.
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 pricingRevisionConflict(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 tableHasColumn(string $table, string $column): bool private function tableHasColumn(string $table, string $column): bool
{ {
$cacheKey = $table . '.' . $column; $cacheKey = $table . '.' . $column;
@@ -1452,13 +1737,19 @@ class limited_backoffice_service
/** /**
* @param array<int, int> $departmentIds * @param array<int, int> $departmentIds
*/ */
private function assertDepartmentSubset(users_o $manager, array $departmentIds): void private function assertDepartmentSubset(
users_o $manager,
array $departmentIds,
?int $authoritativeGroupId = null
): void
{ {
if ($departmentIds === []) { if ($departmentIds === []) {
throw new limited_backoffice_exception('At least one department is required.', 400); throw new limited_backoffice_exception('At least one department is required.', 400);
} }
$managerDepartmentIds = $this->accessibleDepartmentIds($manager); $managerDepartmentIds = $authoritativeGroupId === null
? $this->accessibleDepartmentIds($manager)
: $this->accessibleDepartmentIdsForGroup($authoritativeGroupId);
$outside = array_values(array_diff($departmentIds, $managerDepartmentIds)); $outside = array_values(array_diff($departmentIds, $managerDepartmentIds));
if ($outside !== []) { if ($outside !== []) {
$permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside); $permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside);
@@ -2055,6 +2346,13 @@ class limited_backoffice_service
} }
} }
$db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId); $db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId);
$db->query(
'UPDATE `limited_backoffice_login_grants`
SET `revoked_at` = UTC_TIMESTAMP()
WHERE `target_user_id` = ' . (int)$userId . '
AND `consumed_at` IS NULL
AND `revoked_at` IS NULL'
);
$this->clearUserSessionCache($userId); $this->clearUserSessionCache($userId);
} }
@@ -5,9 +5,14 @@ namespace classes;
use Exception; use Exception;
use mysqli_result; use mysqli_result;
use Throwable; use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class module_usage_service class module_usage_service
{ {
use boolean_normalization_t;
private module_usage_registry $registry; private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null) public function __construct(?module_usage_registry $registry = null)
@@ -968,10 +973,7 @@ class module_usage_service
private function toBool(mixed $value): bool private function toBool(mixed $value): bool
{ {
if (is_bool($value)) { return self::normalizeBoolean($value);
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
} }
private function sqlString(string $value): string private function sqlString(string $value): string
@@ -8,7 +8,6 @@ class object_property
private string $table; // The id of the object in the database 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 $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 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 $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) 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->id = $id;
$this->column = $column; $this->column = $column;
$this->type = $type; $this->type = $type;
$this->required = $required; unset($required); // Retained in the constructor for compatibility with existing object definitions.
$this->default = $default; $this->default = $default;
} }
+83 -13
View File
@@ -7,6 +7,14 @@ use Exception;
use interfaces\openai_i; use interfaces\openai_i;
use openAI\openAI_c; use openAI\openAI_c;
class openai_request_exception extends Exception
{
public function __construct(string $message, public readonly bool $retryable = false, public readonly ?int $httpStatus = null)
{
parent::__construct($message);
}
}
class openai implements openai_i class openai implements openai_i
{ {
/** /**
@@ -44,19 +52,35 @@ class openai implements openai_i
* *
* @throws Exception * @throws Exception
*/ */
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array public function jsonTask(
string $schemaName,
string $prompt,
array $payload,
array $schema,
float $temperature = 0.1,
?string $model = null
): array
{ {
$this->requireModuleEnabled(); $this->requireModuleEnabled();
$data = [ $data = [
'model' => $this->model, 'model' => $model ?? $this->model,
// The caller owns the durable audit record. Do not retain application state at OpenAI.
'store' => false,
'input' => [ 'input' => [
[
'role' => 'developer',
'content' => [[
'type' => 'input_text',
'text' => $prompt,
]],
],
[ [
'role' => 'user', 'role' => 'user',
'content' => [ 'content' => [
[ [
'type' => 'input_text', 'type' => 'input_text',
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'text' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
], ],
], ],
], ],
@@ -73,17 +97,56 @@ class openai implements openai_i
]; ];
$response = $this->sendRequest($data); $response = $this->sendRequest($data);
$output = $response['output'][0]['content'][0]['text'] ?? null; return self::parseJsonTaskResponse($response);
if (!is_string($output) || $output === '') { }
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
public static function parseJsonTaskResponse(array $response): array
{
$status = (string)($response['status'] ?? '');
if ($status === 'incomplete') {
$reason = preg_replace('/[^a-z0-9_.-]/i', '', (string)($response['incomplete_details']['reason'] ?? 'unknown')) ?: 'unknown';
throw new openai_request_exception('OpenAI response was incomplete: ' . $reason, true);
}
if ($status !== 'completed') {
throw new openai_request_exception('OpenAI response did not complete.', in_array($status, ['queued', 'in_progress'], true));
} }
$decoded = json_decode($output, true); $outputText = null;
foreach ((array)($response['output'] ?? []) as $output) {
foreach ((array)($output['content'] ?? []) as $content) {
if (($content['type'] ?? null) === 'refusal') {
throw new openai_request_exception('OpenAI refused the structured task.', false);
}
if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
$outputText = (string)$content['text'];
}
}
}
if ($outputText === null || $outputText === '') {
throw new openai_request_exception('OpenAI completed without structured output text.', false);
}
$decoded = json_decode($outputText, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
throw new Exception('Error parsing JSON response: ' . json_last_error_msg()); throw new openai_request_exception('OpenAI returned invalid structured JSON.', false);
} }
$resolvedModel = trim((string)($response['model'] ?? ''));
return $decoded; if ($resolvedModel === '') {
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
}
$usage = (array)($response['usage'] ?? []);
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
return [
...$decoded,
'_openai_response_model' => $resolvedModel,
'_openai_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $totalTokens,
'service_tier' => (string)($response['service_tier'] ?? ''),
],
];
} }
protected function getLPRSchema(): array protected function getLPRSchema(): array
@@ -278,6 +341,8 @@ class openai implements openai_i
$curl = curl_init($this->api_url); $curl = curl_init($this->api_url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
curl_setopt($curl, CURLOPT_HTTPHEADER, [ curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue() 'Authorization: Bearer ' . $this->config->api_key->getVariableValue()
@@ -285,14 +350,19 @@ class openai implements openai_i
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data)); curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
$response = curl_exec($curl); $response = curl_exec($curl);
if (curl_errno($curl)) { if (curl_errno($curl)) {
throw new Exception('cURL error: ' . curl_error($curl)); $curlCode = curl_errno($curl);
curl_close($curl);
throw new openai_request_exception('OpenAI transport failed.', in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true));
} }
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl); curl_close($curl);
$responseData = json_decode($response, true); $responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Error parsing JSON response: ' . json_last_error_msg()); throw new openai_request_exception('OpenAI returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
}
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
throw new openai_request_exception('OpenAI request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
} }
//print_r($responseData);
return $responseData; return $responseData;
} }
} }
@@ -0,0 +1,73 @@
<?php
namespace classes;
use InvalidArgumentException;
class order_item_reason_policy
{
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
public static function reasons(): array
{
return [
'customer_approved_extra_work' => [
'label' => 'Kunde godkendte ekstra arbejde',
'requires_comment' => true,
'active' => true,
],
'vehicle_condition_extra_work' => [
'label' => 'Køretøjets tilstand krævede ekstra tid',
'requires_comment' => true,
'active' => true,
],
'quality_rework' => [
'label' => 'Kvalitetsopfølgning eller omvask',
'requires_comment' => true,
'active' => true,
],
'legacy_note_only' => [
'label' => 'Legacy note only',
'requires_comment' => true,
'active' => false,
],
];
}
public static function productRequiresReason(int $productId): bool
{
return in_array($productId, self::AFFECTED_PRODUCT_IDS, true);
}
public static function validateForProduct(int $productId, array $data): array
{
if (!self::productRequiresReason($productId)) {
return ['reason_code' => null, 'reason_label_snapshot' => null, 'reason_comment' => null];
}
$code = trim((string)($data['reason_code'] ?? $data['order_item_reason_code'] ?? ''));
if ($code === '') {
throw new InvalidArgumentException('Reason code is required for this product');
}
$reasons = self::reasons();
if (!array_key_exists($code, $reasons)) {
throw new InvalidArgumentException('Reason code is invalid for this product');
}
$reason = $reasons[$code];
if (!$reason['active']) {
throw new InvalidArgumentException('Reason code is deprecated for this product');
}
$comment = trim((string)($data['reason_comment'] ?? $data['comment'] ?? $data['notes'] ?? ''));
if ($reason['requires_comment'] && $comment === '') {
throw new InvalidArgumentException('Reason comment is required for this product');
}
$snapshot = $reason['label'];
return ['reason_code' => $code, 'reason_label_snapshot' => $snapshot, 'reason_comment' => $comment];
}
}
@@ -0,0 +1,241 @@
<?php
namespace classes;
use RuntimeException;
use UnexpectedValueException;
final class order_payment_lock
{
private const LOCK_TIMEOUT_SECONDS = 10;
private const ORDER_RESOURCE = 'order-payment-v1';
private const INVOICE_COLLECTION_RESOURCE = 'invoice-collection-payment-v1';
/** @var list<string> */
private array $lockNames = [];
public static function tryAcquire(int $orderId): ?self
{
return self::tryAcquireResource(self::ORDER_RESOURCE, $orderId);
}
public static function tryAcquireInvoiceCollection(int $invoiceCollectionId): ?self
{
return self::tryAcquireResource(self::INVOICE_COLLECTION_RESOURCE, $invoiceCollectionId);
}
public static function tryAcquireOrderMutation(int $orderId): ?self
{
return self::tryAcquireOrderMutations([$orderId]);
}
/**
* @param list<int> $orderIds
*/
public static function tryAcquireOrderMutations(array $orderIds): ?self
{
$ids = array_values(array_unique(array_filter(
array_map('intval', $orderIds),
static fn(int $id): bool => $id > 0
)));
sort($ids, SORT_NUMERIC);
$collectionByOrder = [];
foreach ($ids as $id) {
$collectionByOrder[$id] = self::invoiceCollectionIdForOrder($id);
}
$collectionIds = array_values(array_unique(array_filter(
$collectionByOrder,
static fn(int $id): bool => $id > 0
)));
sort($collectionIds, SORT_NUMERIC);
$resources = array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$collectionIds
);
array_push(
$resources,
...array_map(
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
$ids
)
);
$lock = self::tryAcquireNames($resources);
if ($lock !== null) {
foreach ($collectionByOrder as $id => $invoiceCollectionId) {
if (self::invoiceCollectionIdForOrder($id) !== $invoiceCollectionId) {
$lock->release();
return null;
}
}
}
return $lock;
}
public static function tryAcquireReassignment(int $orderId, int $targetInvoiceCollectionId): ?self
{
$sourceInvoiceCollectionId = self::invoiceCollectionIdForOrder($orderId);
$collectionIds = array_values(array_unique(array_filter([
$sourceInvoiceCollectionId,
$targetInvoiceCollectionId,
], static fn(int $id): bool => $id > 0)));
sort($collectionIds, SORT_NUMERIC);
$resources = array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$collectionIds
);
$resources[] = self::resourceName(self::ORDER_RESOURCE, $orderId);
$lock = self::tryAcquireNames($resources);
if ($lock !== null && self::invoiceCollectionIdForOrder($orderId) !== $sourceInvoiceCollectionId) {
$lock->release();
return null;
}
return $lock;
}
/**
* @param list<int> $invoiceCollectionIds
*/
public static function tryAcquireInvoiceCollections(array $invoiceCollectionIds): ?self
{
$ids = array_values(array_unique(array_filter(
array_map('intval', $invoiceCollectionIds),
static fn(int $id): bool => $id > 0
)));
sort($ids, SORT_NUMERIC);
return self::tryAcquireNames(array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$ids
));
}
public static function tryAcquireInvoiceCollectionWithOrders(int $invoiceCollectionId): ?self
{
$lock = self::tryAcquireInvoiceCollection($invoiceCollectionId);
if ($lock === null) {
return null;
}
try {
$lock->acquireNames(array_map(
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
self::orderIdsForInvoiceCollection($invoiceCollectionId)
));
return $lock;
} catch (UnexpectedValueException) {
$lock->release();
return null;
}
}
private static function tryAcquireResource(string $resource, int $resourceId): ?self
{
return self::tryAcquireNames([self::resourceName($resource, $resourceId)]);
}
/**
* @param list<string> $lockNames
*/
private static function tryAcquireNames(array $lockNames): ?self
{
try {
return new self($lockNames);
} catch (UnexpectedValueException) {
return null;
}
}
private static function resourceName(string $resource, int $resourceId): string
{
if ($resourceId <= 0) {
throw new RuntimeException('A valid resource ID is required for the payment lock.');
}
return $resource . ':' . $resourceId;
}
private static function invoiceCollectionIdForOrder(int $orderId): int
{
global $db;
if ($orderId <= 0) {
throw new RuntimeException('A valid order ID is required for the payment lock.');
}
$result = $db->query(
'SELECT `invoice_collection_id` FROM `orders` WHERE `id` = ' . $orderId . ' LIMIT 1'
);
$row = $result ? $result->fetch_assoc() : null;
return (int)($row['invoice_collection_id'] ?? 0);
}
/**
* @return list<int>
*/
private static function orderIdsForInvoiceCollection(int $invoiceCollectionId): array
{
global $db;
$result = $db->query(
'SELECT `id` FROM `orders` WHERE `invoice_collection_id` = '
. $invoiceCollectionId . ' ORDER BY `id` ASC'
);
$ids = [];
while ($result && ($row = $result->fetch_assoc())) {
$ids[] = (int)$row['id'];
}
return $ids;
}
/**
* @param list<string> $lockNames
*/
private function __construct(array $lockNames)
{
$this->acquireNames($lockNames);
}
/**
* @param list<string> $lockNames
*/
private function acquireNames(array $lockNames): void
{
global $db;
foreach ($lockNames as $lockName) {
if (in_array($lockName, $this->lockNames, true)) {
continue;
}
$statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired');
if ($statement === false) {
$this->release();
throw new RuntimeException('Unable to prepare the order payment lock.');
}
$timeout = self::LOCK_TIMEOUT_SECONDS;
$statement->bind_param('si', $lockName, $timeout);
$statement->execute();
$result = $statement->get_result()->fetch_assoc();
$statement->close();
if ((int)($result['acquired'] ?? 0) !== 1) {
$this->release();
throw new UnexpectedValueException(
'The order or invoice collection is currently being changed or paid. Try again.'
);
}
$this->lockNames[] = $lockName;
}
}
public function release(): void
{
global $db;
foreach (array_reverse($this->lockNames) as $lockName) {
$statement = $db->prepare('SELECT RELEASE_LOCK(?)');
if ($statement !== false) {
$statement->bind_param('s', $lockName);
$statement->execute();
$statement->close();
}
}
$this->lockNames = [];
}
public function __destruct()
{
$this->release();
}
}
@@ -41,11 +41,38 @@ class orders_schema_bootstrap
); );
} }
if (self::tableExists($db, 'order_items')) {
if (!self::columnExists($db, 'order_items', 'reason_code')) {
$db->query(
"ALTER TABLE order_items
ADD COLUMN reason_code VARCHAR(64) NULL DEFAULT NULL
AFTER include_in_invoice"
);
}
if (!self::columnExists($db, 'order_items', 'reason_label_snapshot')) {
$db->query(
"ALTER TABLE order_items
ADD COLUMN reason_label_snapshot VARCHAR(255) NULL DEFAULT NULL
AFTER reason_code"
);
}
if (!self::columnExists($db, 'order_items', 'reason_comment')) {
$db->query(
"ALTER TABLE order_items
ADD COLUMN reason_comment TEXT NULL
AFTER reason_label_snapshot"
);
}
}
self::backfillBookingPoDefaults($db); self::backfillBookingPoDefaults($db);
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at'); self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id'); self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at'); self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
self::ensureIndex($db, 'order_items', 'idx_order_items_reason_code', 'reason_code');
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id'); self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
self::$initialized = true; self::$initialized = true;
+2 -3
View File
@@ -8,12 +8,11 @@ use objects\ratelimit_o;
class ratelimit implements ratelimit_i class ratelimit implements ratelimit_i
{ {
private int $limit; // The number of requests allowed in the time period private int $limit; // The number of requests allowed in the time period
private int $time; // The time period in seconds
public function __construct(int $defaultLimit, int $defaultTime) public function __construct(int $defaultLimit, int $defaultTime)
{ {
$this->limit = $defaultLimit; $this->limit = $defaultLimit;
$this->time = $defaultTime; unset($defaultTime); // The reset interval is managed by the rate-limit maintenance task.
} }
public function enforceIP(string $ip): bool public function enforceIP(string $ip): bool
@@ -26,4 +25,4 @@ class ratelimit implements ratelimit_i
$ratelimit->increment($ratelimit->id, 1); $ratelimit->increment($ratelimit->id, 1);
return true; return true;
} }
} }
+20 -2
View File
@@ -339,10 +339,10 @@ class redis implements redis_i
/** /**
* Cache auth session payload for a token with TTL * Cache auth session payload for a token with TTL
*/ */
public function cache_auth_session(string $token, array $data, int $ttl = 60): self public function cache_auth_session(string $token, array $session, int $ttl = 60): self
{ {
$key = 'auth_session_' . $token; $key = 'auth_session_' . $token;
$this->set_array($key, $data); $this->set_array($key, $session);
$this->expire($key, $ttl); $this->expire($key, $ttl);
return $this; return $this;
} }
@@ -636,6 +636,24 @@ class redis implements redis_i
return $this; return $this;
} }
/**
* Atomically increments a fixed-window counter and assigns its TTL on the
* first increment. This avoids the GET/SET race in public abuse controls.
*/
public function incrementWithExpiration(string $key, int $seconds): int
{
if (!self::is_connected()) {
self::connect();
}
$count = (int)$this->redis->incr($key);
if ($count === 1) {
$this->redis->expire($key, max(1, $seconds));
}
return $count;
}
public function generateTemporaryCacheKey(): string public function generateTemporaryCacheKey(): string
{ {
// Generate a temporary cache key // Generate a temporary cache key
+56 -11
View File
@@ -5,11 +5,15 @@ namespace classes;
use customers\economicCustomers; use customers\economicCustomers;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/cors_policy.php'; require_once __DIR__ . '/cors_policy.php';
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class release_manager class release_manager
{ {
use boolean_normalization_t;
private const APPS = ['frontend', 'api']; private const APPS = ['frontend', 'api'];
private const DEFAULT_BRANCH = 'master'; private const DEFAULT_BRANCH = 'master';
private const RELEASE_ROUTE_SLUGS = [ private const RELEASE_ROUTE_SLUGS = [
@@ -378,6 +382,39 @@ class release_manager
return $expected !== '' && hash_equals($expected, $token); return $expected !== '' && hash_equals($expected, $token);
} }
public static function normalizeFrontendVersionGateInput(array $payload): array
{
$version = strtolower(trim((string)($payload['version'] ?? '')));
if (preg_match('/^[a-f0-9]{40}$/', $version) !== 1) {
throw new RuntimeException('Frontend release version must be a full commit SHA.');
}
$repository = self::normalizeGithubRepositoryName((string)($payload['repository'] ?? ''));
$expectedRepository = self::normalizeGithubRepositoryName(
self::runtimeEnvValue('RELEASE_MANAGER_FRONTEND_REPOSITORY') ?: 'copenhagentruckwash/pleno-vue'
);
if ($repository === '' || strtolower($repository) !== strtolower($expectedRepository)) {
throw new RuntimeException('Frontend release repository is not authorized.');
}
$branch = strtolower(trim((string)($payload['branch'] ?? '')));
if ($branch !== self::DEFAULT_BRANCH) {
throw new RuntimeException('Frontend release branch must be master.');
}
$buildId = trim((string)($payload['build_id'] ?? ''));
if (preg_match('/^[1-9][0-9]*-[1-9][0-9]*$/', $buildId) !== 1) {
throw new RuntimeException('Frontend release build id is invalid.');
}
return [
'version' => $version,
'repository' => $repository,
'branch' => $branch,
'build_id' => $buildId,
];
}
public static function normalizeGithubRepositoryName(string $value): string public static function normalizeGithubRepositoryName(string $value): string
{ {
$repository = trim($value); $repository = trim($value);
@@ -5680,7 +5717,7 @@ class release_manager
$channelId = $this->nullablePositiveInt($input['channel_id'] ?? null); $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
$channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? '')); $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
if ($channelId === null && $channelSlug !== '') { if ($channelId === null && $channelSlug !== '') {
$channel = $this->channelBySlug($channelSlug); $channel = $this->findChannelBySlug($channelSlug);
if ($channel === null) { if ($channel === null) {
throw new RuntimeException('Release channel was not found for Coolify cleanup.'); throw new RuntimeException('Release channel was not found for Coolify cleanup.');
} }
@@ -8408,7 +8445,9 @@ class release_manager
$publicUrl, $publicUrl,
$resourceUuid, $resourceUuid,
$routePort, $routePort,
self::gatewayRouteDefaultCertResolver($publicUrl) self::gatewayRouteDefaultCertResolver($publicUrl),
(string)($target['app'] ?? ''),
(string)($this->releaseCoolifyRuntimeEnv($target, $context)['CORS'] ?? '')
); );
if ($labels !== []) { if ($labels !== []) {
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
@@ -8461,7 +8500,9 @@ class release_manager
string $publicUrl, string $publicUrl,
string $resourceUuid, string $resourceUuid,
?int $port = null, ?int $port = null,
?string $certResolver = null ?string $certResolver = null,
string $app = '',
string $corsConfig = ''
): array ): array
{ {
$resourceUuid = self::coolifyRouteLabelId($resourceUuid); $resourceUuid = self::coolifyRouteLabelId($resourceUuid);
@@ -8487,6 +8528,7 @@ class release_manager
$httpLabel = 'http-0-' . $resourceUuid; $httpLabel = 'http-0-' . $resourceUuid;
$httpsLabel = 'https-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid;
$priority = (string)(1000 + strlen($path)); $priority = (string)(1000 + strlen($path));
$isApi = strtolower(trim($app)) === 'api';
$labels = [ $labels = [
'traefik.enable=true', 'traefik.enable=true',
'traefik.http.middlewares.gzip.compress=true', 'traefik.http.middlewares.gzip.compress=true',
@@ -8501,12 +8543,18 @@ class release_manager
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; $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 !== '/') { if ($path !== '/') {
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; $httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
} else {
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
} }
$httpsMiddlewares[] = 'gzip';
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
if ($certResolver !== '') { if ($certResolver !== '') {
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
@@ -9346,7 +9394,7 @@ class release_manager
} }
$raw = trim($raw); $raw = trim($raw);
$raw = preg_replace('#[/\s].*$#', '', $raw) ?? ''; $raw = preg_replace('#[/\s].*$#', '', $raw) ?? '';
if (str_contains($raw, ':') && preg_match('/^\[[^\]]+\]:(\d+)$/', $raw) !== 1) { if (str_contains($raw, ':') && preg_match('/^\x5b[^\x5d]+\x5d:(\d+)$/', $raw) !== 1) {
$parts = parse_url('https://' . $raw); $parts = parse_url('https://' . $raw);
if (is_array($parts) && !empty($parts['host'])) { if (is_array($parts) && !empty($parts['host'])) {
$raw = (string)$parts['host']; $raw = (string)$parts['host'];
@@ -12621,10 +12669,7 @@ class release_manager
private function toBool(mixed $value): bool private function toBool(mixed $value): bool
{ {
if (is_bool($value)) { return self::normalizeBoolean($value);
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
} }
private function requestTraceId(): string private function requestTraceId(): string
@@ -2,8 +2,14 @@
namespace classes; namespace classes;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class releasemanager class releasemanager
{ {
use boolean_normalization_t;
public function isEnabled(): bool public function isEnabled(): bool
{ {
try { try {
@@ -11,7 +17,7 @@ class releasemanager
global $db; global $db;
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1"); $result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
$row = $result ? $result->fetch_assoc() : null; $row = $result ? $result->fetch_assoc() : null;
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true); return self::normalizeBoolean((string)($row['value'] ?? 'true'));
} catch (\Throwable) { } catch (\Throwable) {
return true; return true;
} }
@@ -6,9 +6,14 @@ use Aws\S3\S3Client;
use mysqli; use mysqli;
use Predis\Client as PredisClient; use Predis\Client as PredisClient;
use Throwable; use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class replica_failover_manager class replica_failover_manager
{ {
use boolean_normalization_t;
public const KIND_DATABASE = 'database'; public const KIND_DATABASE = 'database';
public const KIND_REDIS = 'redis'; public const KIND_REDIS = 'redis';
public const KIND_MINIO = 'minio'; public const KIND_MINIO = 'minio';
@@ -502,11 +507,7 @@ class replica_failover_manager
private static function boolValue(mixed $value): bool private static function boolValue(mixed $value): bool
{ {
if (is_bool($value)) { return self::normalizeBoolean($value);
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
} }
private static function jsonDecode(mixed $value): array private static function jsonDecode(mixed $value): array
@@ -0,0 +1,208 @@
<?php
namespace classes;
use Exception;
/**
* Purpose-bound one-time tokens. Only SHA-256 digests are persisted.
*/
class subuser_action_token_service
{
public const PURPOSE_GRANT_APPROVE = 'grant_approve';
public const PURPOSE_GRANT_DENY = 'grant_deny';
public const PURPOSE_PASSWORD_RESET = 'password_reset';
public const TOKEN_BYTES = 32;
public const GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60;
public const PASSWORD_RESET_TTL_SECONDS = 60 * 60;
public function issue(string $purpose, int $subuserId, ?int $grantId = null, ?int $customerNumber = null, ?int $ttlSeconds = null): string
{
global $db;
$this->assertPurpose($purpose);
if ($subuserId <= 0) {
throw new Exception('Invalid subuser action token subject');
}
$token = bin2hex(random_bytes(self::TOKEN_BYTES));
$tokenHash = hash('sha256', $token);
$ttlSeconds ??= $purpose === self::PURPOSE_PASSWORD_RESET
? self::PASSWORD_RESET_TTL_SECONDS
: self::GRANT_DECISION_TTL_SECONDS;
$expiresAt = gmdate('Y-m-d H:i:s', time() + max(60, $ttlSeconds));
$statement = $db->conn->prepare(
'INSERT INTO subuser_action_tokens '
. '(token_hash, purpose, subuser_id, grant_id, customer_number, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new Exception('Failed to prepare subuser action token');
}
$statement->bind_param('ssiiis', $tokenHash, $purpose, $subuserId, $grantId, $customerNumber, $expiresAt);
$statement->execute();
$statement->close();
return $token;
}
public function inspect(string $token, ?string $expectedPurpose = null): ?array
{
global $db;
$token = strtolower($token);
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
if ($expectedPurpose !== null) {
$this->assertPurpose($expectedPurpose);
}
$tokenHash = hash('sha256', $token);
$sql = 'SELECT id, purpose, subuser_id, grant_id, customer_number, expires_at FROM subuser_action_tokens '
. "WHERE token_hash = '" . $db->escape_string($tokenHash) . "' "
. 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()';
if ($expectedPurpose !== null) {
$sql .= " AND purpose = '" . $db->escape_string($expectedPurpose) . "'";
}
$result = $db->query($sql . ' LIMIT 1');
if ($result === false || $result->num_rows === 0) {
return null;
}
$row = $result->fetch_assoc();
return [
'id' => (int)$row['id'],
'purpose' => (string)$row['purpose'],
'subuser_id' => (int)$row['subuser_id'],
'grant_id' => $row['grant_id'] === null ? null : (int)$row['grant_id'],
'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'],
'expires_at' => (string)$row['expires_at'],
];
}
public function consume(string $token, string $expectedPurpose): ?array
{
global $db;
$record = $this->inspect($token, $expectedPurpose);
if ($record === null) {
return null;
}
$statement = $db->conn->prepare(
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
. 'WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()'
);
if ($statement === false) {
throw new Exception('Failed to consume subuser action token');
}
$id = (int)$record['id'];
$statement->bind_param('i', $id);
$statement->execute();
$consumed = $statement->affected_rows === 1;
$statement->close();
return $consumed ? $record : null;
}
public function consumeGrantDecision(string $token): ?array
{
global $db;
$preview = $this->inspect($token);
if (
$preview === null
|| $preview['grant_id'] === null
|| !in_array($preview['purpose'], [
self::PURPOSE_GRANT_APPROVE,
self::PURPOSE_GRANT_DENY,
], true)
) {
return null;
}
$db->conn->begin_transaction();
try {
// Serializing on the grant row prevents simultaneous approve and
// deny links from racing and applying opposite final states.
$lock = $db->conn->prepare(
'SELECT id FROM subuser_grants '
. 'WHERE id = ? AND subuser = ? AND billing_customer_number = ? AND deleted_at IS NULL '
. 'FOR UPDATE'
);
if ($lock === false) {
throw new Exception('Failed to lock subuser grant decision');
}
$grantId = (int)$preview['grant_id'];
$subuserId = (int)$preview['subuser_id'];
$customerNumber = (int)$preview['customer_number'];
$lock->bind_param('iii', $grantId, $subuserId, $customerNumber);
$lock->execute();
$lock->store_result();
$grantExists = $lock->num_rows === 1;
$lock->close();
if (!$grantExists) {
$db->conn->rollback();
return null;
}
$record = $this->consume($token, (string)$preview['purpose']);
if ($record === null) {
$db->conn->rollback();
return null;
}
$enabled = $record['purpose'] === self::PURPOSE_GRANT_APPROVE ? 1 : 0;
$update = $db->conn->prepare('UPDATE subuser_grants SET enabled = ? WHERE id = ?');
if ($update === false) {
throw new Exception('Failed to apply subuser grant decision');
}
$update->bind_param('ii', $enabled, $grantId);
$update->execute();
$applied = $update->affected_rows === 1 || $update->warning_count === 0;
$update->close();
if (!$applied) {
throw new Exception('Failed to apply subuser grant decision');
}
$this->revokeGrantDecisions($grantId);
$db->conn->commit();
return $record;
} catch (Exception $exception) {
$db->conn->rollback();
throw $exception;
}
}
public function revokeForSubuser(int $subuserId, string $purpose): void
{
global $db;
$this->assertPurpose($purpose);
$statement = $db->conn->prepare(
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() WHERE subuser_id = ? AND purpose = ? AND used_at IS NULL'
);
if ($statement === false) {
throw new Exception('Failed to revoke subuser action tokens');
}
$statement->bind_param('is', $subuserId, $purpose);
$statement->execute();
$statement->close();
}
public function revokeGrantDecisions(int $grantId): void
{
global $db;
$statement = $db->conn->prepare(
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
. 'WHERE grant_id = ? AND purpose IN (?, ?) AND used_at IS NULL'
);
if ($statement === false) {
throw new Exception('Failed to revoke grant decision tokens');
}
$approve = self::PURPOSE_GRANT_APPROVE;
$deny = self::PURPOSE_GRANT_DENY;
$statement->bind_param('iss', $grantId, $approve, $deny);
$statement->execute();
$statement->close();
}
private function assertPurpose(string $purpose): void
{
if (!in_array($purpose, [
self::PURPOSE_GRANT_APPROVE,
self::PURPOSE_GRANT_DENY,
self::PURPOSE_PASSWORD_RESET,
], true)) {
throw new Exception('Invalid subuser action token purpose');
}
}
}
@@ -38,10 +38,41 @@ class subusers_schema_bootstrap
'email_verified_at', 'email_verified_at',
'DATETIME NULL AFTER `email`' 'DATETIME NULL AFTER `email`'
); );
self::ensureTable(
'subuser_action_tokens',
<<<'SQL'
CREATE TABLE IF NOT EXISTS `subuser_action_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`token_hash` CHAR(64) NOT NULL,
`purpose` VARCHAR(32) NOT NULL,
`subuser_id` INT UNSIGNED NOT NULL,
`grant_id` INT UNSIGNED NULL,
`customer_number` INT NULL,
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`),
KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`),
KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL
);
self::$initialized = true; self::$initialized = true;
} }
private static function ensureTable(string $table, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if ($table === '') {
return;
}
$db->query($definition);
}
private static function ensureColumn(string $table, string $column, string $definition): void private static function ensureColumn(string $table, string $column, string $definition): void
{ {
global $db; global $db;
@@ -4,9 +4,14 @@ namespace classes;
use Aws\S3\S3Client; use Aws\S3\S3Client;
use Throwable; use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class superuser_system_status_service class superuser_system_status_service
{ {
use boolean_normalization_t;
public const MODULE_PROBE_TTL_SECONDS = 60; public const MODULE_PROBE_TTL_SECONDS = 60;
public const REFRESH_AFTER_SECONDS = 30; public const REFRESH_AFTER_SECONDS = 30;
private const MODULE_PROBE_CACHE_KEY_PREFIX = 'superuser_system_status:module_probe:'; private const MODULE_PROBE_CACHE_KEY_PREFIX = 'superuser_system_status:module_probe:';
@@ -526,7 +531,6 @@ class superuser_system_status_service
'enabled' => $enabled, 'enabled' => $enabled,
'configured' => $configured, 'configured' => $configured,
'probe_supported' => isset($descriptor['probe']), 'probe_supported' => isset($descriptor['probe']),
'status' => 'configured',
'status_reason' => null, 'status_reason' => null,
'status_reason_key' => null, 'status_reason_key' => null,
'status_reason_params' => [], 'status_reason_params' => [],
@@ -848,7 +852,7 @@ class superuser_system_status_service
protected function parseModuleConfigValue(string $type, mixed $value): mixed protected function parseModuleConfigValue(string $type, mixed $value): mixed
{ {
return match (strtolower($type)) { return match (strtolower($type)) {
'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true), 'bool' => self::normalizeBoolean($value),
'int', 'integer' => is_numeric($value) ? (int)$value : null, 'int', 'integer' => is_numeric($value) ? (int)$value : null,
'float', 'double' => is_numeric($value) ? (float)$value : null, 'float', 'double' => is_numeric($value) ? (float)$value : null,
'json' => is_string($value) ? json_decode($value, true) : null, 'json' => is_string($value) ? json_decode($value, true) : null,
@@ -671,7 +671,7 @@ class system_search_service
/** /**
* @param array<int, mixed> $entityIds * @param array<int, mixed> $entityIds
* @return array<string, array{name:?string,created_at:?string,closed_at:?string}> * @return array<string, array{name: ?string, created_at: ?string, closed_at: ?string}>
*/ */
private function loadInvoiceTitleContexts(array $entityIds): array private function loadInvoiceTitleContexts(array $entityIds): array
{ {
@@ -2283,7 +2283,7 @@ class system_search_service
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>} * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/ */
private function resolveObjectSearchContext(array $row): array private function resolveObjectSearchContext(array $row): array
{ {
@@ -2296,7 +2296,7 @@ class system_search_service
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>} * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/ */
private function resolveOrderObjectSearchContext(array $row): array private function resolveOrderObjectSearchContext(array $row): array
{ {
@@ -2336,7 +2336,7 @@ class system_search_service
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>} * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/ */
private function resolveTaskObjectSearchContext(array $row): array private function resolveTaskObjectSearchContext(array $row): array
{ {
@@ -2379,7 +2379,7 @@ class system_search_service
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>} * @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/ */
private function resolveGenericObjectSearchContext(array $row): array private function resolveGenericObjectSearchContext(array $row): array
{ {
-1
View File
@@ -96,7 +96,6 @@ class webauthn
$pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value())); $pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value()));
error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)"); error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)"); throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
return false;
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
throw new Exception('Serialization error: ' . $e->getMessage()); throw new Exception('Serialization error: ' . $e->getMessage());
} }
@@ -81,10 +81,7 @@ class wordpress_bookings_remote implements wordpress_bookings_remote_i
} else { } else {
$booking = $booking["data"]["booking"]; $booking = $booking["data"]["booking"];
} }
} else if (isset($booking["id"])) { } else if (!isset($booking["id"])) {
// Check if the booking property is set
} else {
return null; return null;
} }
@@ -157,4 +154,4 @@ class wordpress_bookings_remote implements wordpress_bookings_remote_i
// Get the booking cache // Get the booking cache
return $this->booking_cache; return $this->booking_cache;
} }
} }
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ namespace classes;
*/ */
class xlvask_usage_logs_schema_bootstrap class xlvask_usage_logs_schema_bootstrap
{ {
public const MIGRATION_VERSION = '20260804_xlvask_ai_auto_policy_v2';
private static bool $initialized = false; private static bool $initialized = false;
public static function ensureTables(): void public static function ensureTables(): void
@@ -14,15 +15,34 @@ class xlvask_usage_logs_schema_bootstrap
if (self::$initialized) { if (self::$initialized) {
return; return;
} }
$status = self::migrationStatus();
if (!$status['ready']) {
throw new \RuntimeException(
'XL Vask automation schema is not ready. Apply migration ' . self::MIGRATION_VERSION . ' explicitly.'
);
}
self::$initialized = true;
}
/**
* Explicit operator-invoked migration entrypoint. Request handlers and workers must never call this method.
*/
public static function applyExplicitMigration(): array
{
global $db; global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return; throw new \RuntimeException('The database connection is unavailable.');
} }
if (!self::tableExists($db, 'xlvask_usage_logs')) { if (!self::tableExists($db, 'xlvask_usage_logs')) {
return; throw new \RuntimeException('The xlvask_usage_logs table is unavailable.');
}
$conflicts = self::activeExecuteRunConflicts($db);
if ($conflicts !== []) {
throw new \RuntimeException(
'XL Vask automation migration is blocked by existing active execute runs: ' . implode(', ', $conflicts)
);
} }
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
@@ -31,9 +51,148 @@ class xlvask_usage_logs_schema_bootstrap
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
self::ensureAutomationTables($db); self::ensureAutomationTables($db);
self::$initialized = true; self::$initialized = false;
$status = self::migrationStatus();
if (!$status['ready']) {
throw new \RuntimeException('XL Vask automation migration did not reach a ready state.');
}
return $status;
}
/** Read-only preflight used by readiness endpoints and normal request/worker entrypoints. */
public static function migrationStatus(): array
{
global $db;
$missingTables = [];
$missingColumns = [];
$requiredIndexes = [
'xlvask_autopilot_runs.uniq_xlvask_active_execute_run',
'xlvask_autopilot_runs.uniq_xlvask_autopilot_run_idempotency',
'xlvask_automation_action_events.uniq_xlvask_action_event_suggestion',
];
$missingIndexes = [];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return [
'version' => self::MIGRATION_VERSION,
'ready' => false,
'missing_tables' => ['database'],
'missing_columns' => [],
'required_indexes' => $requiredIndexes,
'missing_indexes' => $requiredIndexes,
'preflight_conflicts' => ['database_unavailable'],
];
}
foreach ([
'xlvask_usage_logs', 'xlvask_automation_suggestions', 'xlvask_automation_feedback',
'xlvask_automation_openai_cache', 'xlvask_autopilot_runs', 'xlvask_autopilot_run_items',
'xlvask_automation_audit', 'xlvask_automation_calibrations',
'xlvask_automation_calibration_label_events', 'xlvask_automation_decision_previews',
'xlvask_automation_policy_state', 'xlvask_automation_policy_previews',
'xlvask_automation_policy_events', 'xlvask_automation_action_events',
] as $table) {
if (!self::tableExists($db, $table)) {
$missingTables[] = $table;
}
}
$requiredColumns = [
'xlvask_usage_logs' => [
'ignored_at', 'ignored_by', 'ignored_reason', 'cached_total_net_amount',
'cached_primary_product_name', 'cached_amount_at', 'source_hash', 'source_revision',
'source_observed_at', 'source_stable_since', 'source_observation_count', 'import_state',
'resolution_state', 'certainty', 'planned_action', 'state_reason', 'expected_version',
'last_run_id', 'last_evaluated_at',
],
'xlvask_automation_suggestions' => [
'run_id', 'policy_version', 'planner_identity_hash', 'model', 'model_confidence',
'calibrated_probability', 'certainty', 'evidence_json', 'contradictions_json',
'risk_flags_json', 'plan_steps_json', 'expected_version', 'input_hash',
],
'xlvask_autopilot_runs' => [
'idempotency_key', 'mode', 'status', 'phase', 'date_from', 'date_to', 'force_refetch',
'requested_ids_json', 'requested_limit', 'request_hash', 'scope_hall_ids_json',
'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token',
'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by',
'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot',
'ai_timeline', 'ai_batch_size', 'ai_max_cost_usd',
'ai_input_usd_per_1m_usd', 'ai_output_usd_per_1m_usd',
'ai_requests', 'ai_cache_hits', 'ai_input_tokens', 'ai_output_tokens',
'ai_total_tokens', 'ai_estimated_cost_usd', 'ai_budget_exhausted',
],
'xlvask_autopilot_run_items' => [
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
'planned_action', 'source_hash', 'expected_version', 'result_json', 'error', 'created_at', 'updated_at',
],
'xlvask_automation_calibrations' => [
'policy_version', 'segment_key', 'automation_identity_hash', 'precision_value',
'wilson_lower_bound', 'holdout_examples', 'segment_examples', 'contradictions',
'calibrated_probability', 'artifact_hash', 'active', 'backtest_json', 'created_by',
'activated_by', 'activated_at', 'invalidated_at', 'created_at',
],
'xlvask_automation_calibration_label_events' => [
'suggestion_id', 'outcome', 'adjudication_outcome', 'adjudicated_by',
'adjudicated_at', 'legacy_label_id',
],
'xlvask_automation_policy_state' => [
'policy_version', 'planner_identity_hash', 'stage', 'halted', 'attach_enabled',
'create_enabled', 'halt_reason', 'attach_halt_reason', 'create_halt_reason',
'halted_at', 'halted_by', 'attach_activated_at', 'attach_activated_by',
'create_activated_at', 'create_activated_by', 'expected_version', 'created_at', 'updated_at',
],
'xlvask_automation_policy_previews' => [
'selection_hash', 'requested_transition', 'payload_json', 'created_by',
'expires_at', 'applied_at', 'created_at',
],
'xlvask_automation_policy_events' => ['event_type', 'actor_id', 'details_json', 'created_at'],
'xlvask_automation_action_events' => [
'suggestion_id', 'run_id', 'hall_id', 'action', 'source', 'policy_version',
'planner_identity_hash', 'review_outcome', 'reviewed_by', 'reviewed_at', 'created_at',
],
];
foreach ($requiredColumns as $table => $columns) {
if (!self::tableExists($db, $table)) {
continue;
}
foreach ($columns as $column) {
if (!self::columnExists($db, $table, $column)) {
$missingColumns[] = $table . '.' . $column;
}
}
}
foreach ($requiredIndexes as $requiredIndex) {
[$table, $index] = explode('.', $requiredIndex, 2);
if (!self::indexExists($db, $table, $index)) {
$missingIndexes[] = $requiredIndex;
}
}
$conflicts = self::activeExecuteRunConflicts($db);
return [
'version' => self::MIGRATION_VERSION,
'ready' => $missingTables === [] && $missingColumns === [] && $missingIndexes === [] && $conflicts === [],
'missing_tables' => $missingTables,
'missing_columns' => $missingColumns,
'required_indexes' => $requiredIndexes,
'missing_indexes' => $missingIndexes,
'preflight_conflicts' => $conflicts,
];
} }
private static function ensureAutomationTables(object $db): void private static function ensureAutomationTables(object $db): void
@@ -68,6 +227,24 @@ class xlvask_usage_logs_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
); );
foreach ([
'run_id' => 'BIGINT NULL AFTER usage_log_id',
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
'planner_identity_hash' => 'CHAR(64) NULL AFTER policy_version',
'model' => 'VARCHAR(96) NULL AFTER planner_identity_hash',
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
'evidence_json' => 'LONGTEXT NULL AFTER certainty',
'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json',
'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json',
'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json',
'expected_version' => 'INT NULL AFTER plan_steps_json',
'input_hash' => 'CHAR(64) NULL AFTER expected_version',
] as $column => $definition) {
self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition);
}
$db->query( $db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
`id` INT NOT NULL AUTO_INCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
@@ -104,15 +281,375 @@ class xlvask_usage_logs_schema_bootstrap
KEY `idx_xlvask_openai_cache_schema` (`schema_name`) KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
); );
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`idempotency_key` CHAR(64) NOT NULL,
`mode` VARCHAR(16) NOT NULL,
`status` VARCHAR(24) NOT NULL DEFAULT 'queued',
`phase` VARCHAR(32) NOT NULL DEFAULT 'queued',
`date_from` DATE NULL,
`date_to` DATE NULL,
`force_refetch` TINYINT(1) NOT NULL DEFAULT 0,
`requested_ids_json` LONGTEXT NULL,
`requested_limit` INT NOT NULL DEFAULT 500,
`request_hash` CHAR(64) NOT NULL,
`scope_hall_ids_json` LONGTEXT NOT NULL,
`processed` INT NOT NULL DEFAULT 0,
`total` INT NOT NULL DEFAULT 0,
`summary_json` LONGTEXT NULL,
`warning` TEXT NULL,
`error` TEXT NULL,
`lease_token` CHAR(36) NULL,
`lease_expires_at` DATETIME NULL,
`attempt_count` INT NOT NULL DEFAULT 0,
`max_attempts` INT NOT NULL DEFAULT 3,
`next_attempt_at` DATETIME NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`started_at` DATETIME NULL,
`finished_at` DATETIME NULL,
`active_execute_slot` TINYINT GENERATED ALWAYS AS (
CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END
) STORED,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`),
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_timeline', "VARCHAR(16) NOT NULL DEFAULT 'standard' AFTER scope_hall_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_batch_size', 'INT NOT NULL DEFAULT 150 AFTER ai_timeline');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_max_cost_usd', 'DECIMAL(12,4) NULL AFTER ai_batch_size');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 0.5000 AFTER ai_max_cost_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 2.0000 AFTER ai_input_usd_per_1m_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_requests', 'INT NOT NULL DEFAULT 0 AFTER total');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_cache_hits', 'INT NOT NULL DEFAULT 0 AFTER ai_requests');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_cache_hits');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_input_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_total_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_output_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_estimated_cost_usd', 'DECIMAL(14,6) NOT NULL DEFAULT 0.000000 AFTER ai_total_tokens');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_budget_exhausted', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER ai_estimated_cost_usd');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
self::addColumnIfMissing(
$db,
'xlvask_autopilot_runs',
'active_execute_slot',
"TINYINT GENERATED ALWAYS AS (CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END) STORED AFTER finished_at"
);
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_active_execute_run')) {
if ($db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`)') === false) {
throw new \RuntimeException('The unique active XL Vask execute-run index could not be created.');
}
}
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_autopilot_run_idempotency')
&& $db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') === false) {
throw new \RuntimeException('The unique XL Vask run idempotency index could not be created.');
}
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`run_id` BIGINT NOT NULL,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged',
`resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review',
`certainty` VARCHAR(16) NOT NULL DEFAULT 'none',
`planned_action` VARCHAR(32) NOT NULL DEFAULT 'none',
`source_hash` CHAR(64) NULL,
`expected_version` INT NULL,
`result_json` LONGTEXT NULL,
`error` TEXT 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_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`),
KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`run_id` BIGINT NULL,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`event_type` VARCHAR(48) NOT NULL,
`action` VARCHAR(32) NULL,
`policy_version` VARCHAR(64) NOT NULL,
`input_hash` CHAR(64) NULL,
`source_revision` VARCHAR(128) NULL,
`expected_version` INT NULL,
`before_json` LONGTEXT NULL,
`after_json` LONGTEXT NULL,
`evidence_json` LONGTEXT NULL,
`actor_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`),
KEY `idx_xlvask_audit_run` (`run_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` (
`id` INT NOT NULL AUTO_INCREMENT,
`policy_version` VARCHAR(64) NOT NULL,
`segment_key` VARCHAR(191) NOT NULL,
`precision_value` DECIMAL(7,6) NOT NULL,
`wilson_lower_bound` DECIMAL(7,6) NOT NULL,
`holdout_examples` INT NOT NULL,
`segment_examples` INT NOT NULL,
`contradictions` INT NOT NULL DEFAULT 0,
`calibrated_probability` DECIMAL(7,6) NOT NULL,
`artifact_hash` CHAR(64) NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 0,
`backtest_json` LONGTEXT NULL,
`created_by` INT NULL,
`activated_by` INT NULL,
`activated_at` DATETIME NULL,
`invalidated_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'automation_identity_hash', 'CHAR(64) NULL AFTER segment_key');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'invalidated_at', 'DATETIME NULL AFTER activated_at');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`suggestion_id` INT NOT NULL,
`outcome` VARCHAR(16) NOT NULL,
`adjudication_outcome` VARCHAR(32) NULL,
`adjudicated_by` INT NOT NULL,
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`),
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
// The nullable legacy id supports an idempotent, non-destructive backfill.
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`suggestion_id` INT NOT NULL,
`outcome` VARCHAR(16) NOT NULL,
`adjudicated_by` INT NOT NULL,
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`legacy_label_id` BIGINT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`),
KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`),
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing(
$db,
'xlvask_automation_calibration_label_events',
'adjudication_outcome',
'VARCHAR(32) NULL AFTER outcome'
);
$db->query(
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id
FROM xlvask_automation_calibration_labels"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` (
`id` CHAR(36) NOT NULL,
`selection_hash` CHAR(64) NOT NULL,
`action` VARCHAR(32) NOT NULL,
`payload_json` LONGTEXT NOT NULL,
`created_by` INT NULL,
`expires_at` DATETIME NOT NULL,
`applied_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_state` (
`id` TINYINT NOT NULL,
`policy_version` VARCHAR(64) NOT NULL,
`planner_identity_hash` CHAR(64) NOT NULL,
`stage` VARCHAR(32) NOT NULL DEFAULT 'off',
`halted` TINYINT(1) NOT NULL DEFAULT 0,
`attach_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`create_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`halt_reason` TEXT NULL,
`attach_halt_reason` TEXT NULL,
`create_halt_reason` TEXT NULL,
`halted_at` DATETIME NULL,
`halted_by` INT NULL,
`attach_activated_at` DATETIME NULL,
`attach_activated_by` INT NULL,
`create_activated_at` DATETIME NULL,
`create_activated_by` INT NULL,
`expected_version` INT 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 (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'stage', "VARCHAR(32) NOT NULL DEFAULT 'off' AFTER planner_identity_hash");
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'attach_halt_reason', 'TEXT NULL AFTER halt_reason');
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'create_halt_reason', 'TEXT NULL AFTER attach_halt_reason');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_previews` (
`id` CHAR(36) NOT NULL,
`selection_hash` CHAR(64) NOT NULL,
`requested_transition` VARCHAR(32) NOT NULL,
`payload_json` LONGTEXT NOT NULL,
`created_by` INT NOT NULL,
`expires_at` DATETIME NOT NULL,
`applied_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_policy_preview_expiry` (`expires_at`, `applied_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`event_type` VARCHAR(48) NOT NULL,
`actor_id` INT NOT NULL,
`details_json` LONGTEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_policy_event_time` (`created_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_action_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`suggestion_id` INT NOT NULL,
`run_id` BIGINT NULL,
`hall_id` VARCHAR(191) NOT NULL,
`action` VARCHAR(32) NOT NULL,
`source` VARCHAR(32) NOT NULL,
`policy_version` VARCHAR(64) NOT NULL,
`planner_identity_hash` CHAR(64) NOT NULL,
`review_outcome` VARCHAR(32) NULL,
`reviewed_by` INT NULL,
`reviewed_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`),
KEY `idx_xlvask_action_budget` (`action`, `created_at`),
KEY `idx_xlvask_action_hall_budget` (`hall_id`, `action`, `created_at`),
KEY `idx_xlvask_action_soak` (`source`, `action`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'hall_id', "VARCHAR(191) NOT NULL DEFAULT '' AFTER run_id");
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'review_outcome', 'VARCHAR(32) NULL AFTER planner_identity_hash');
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_by', 'INT NULL AFTER review_outcome');
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_at', 'DATETIME NULL AFTER reviewed_by');
if (!self::indexExists($db, 'xlvask_automation_action_events', 'uniq_xlvask_action_event_suggestion')
&& $db->query('ALTER TABLE `xlvask_automation_action_events` ADD UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`)') === false) {
throw new \RuntimeException('The unique XL Vask action-event suggestion index could not be created.');
}
}
public static function washIdUniquenessReady(): bool
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return false;
}
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
}
/**
* Explicit activation migration. Never call this from constructors, GETs, or normal runs.
* Returns false without modifying conflicting records when duplicate wash IDs exist.
*/
public static function applyWashIdUniquenessMigration(): bool
{
global $db;
if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) {
return false;
}
if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) {
return true;
}
$duplicates = $db->query(
"SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders`
WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> ''
GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1"
);
if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) {
if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) {
$db->query(
"ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128)
GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED"
);
}
$db->query(
"ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)"
);
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
}
return false;
} }
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
{ {
if (!self::columnExists($db, $table, $column)) { if (!self::columnExists($db, $table, $column)) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); if ($db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}") === false) {
throw new \RuntimeException("The required XL Vask column {$table}.{$column} could not be created.");
}
} }
} }
private static function activeExecuteRunConflicts(object $db): array
{
if (!self::tableExists($db, 'xlvask_autopilot_runs')
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'mode')
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'status')) {
return [];
}
$result = $db->query(
"SELECT COUNT(*) total FROM xlvask_autopilot_runs
WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')"
);
if ($result === false || !is_object($result)) {
return ['active_execute_preflight_unavailable'];
}
$row = $db->fetch_assoc($result);
$count = (int)($row['total'] ?? 0);
return $count > 1 ? ['multiple_active_execute_runs:' . $count] : [];
}
private static function tableExists(object $db, string $table): bool private static function tableExists(object $db, string $table): bool
{ {
$table = self::escapeIdentifier($table); $table = self::escapeIdentifier($table);
@@ -138,6 +675,18 @@ class xlvask_usage_logs_schema_bootstrap
return (int)$result->num_rows > 0; return (int)$result->num_rows > 0;
} }
private static function indexExists(object $db, string $table, string $index): bool
{
if (!self::tableExists($db, $table)) {
return false;
}
$table = self::escapeIdentifier($table);
$index = self::escapeIdentifier($index);
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
return $result !== false && is_object($result) && (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string private static function escapeIdentifier(string $value): string
{ {
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
@@ -18,6 +18,9 @@ try {
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
) . PHP_EOL; ) . PHP_EOL;
} catch (\Throwable $e) { } catch (\Throwable $e) {
// The wrapper cron entry may swallow this throw, so also emit a
// container-log breadcrumb before re-raising.
error_log('[cron-backfill-economic-v2-history] runBestEffortBackfill failed: ' . $e->getMessage());
echo json_encode( echo json_encode(
[ [
'success' => false, 'success' => false,
@@ -18,4 +18,11 @@ if (!defined('WD')) {
$bookings_o = new bookings_o(); $bookings_o = new bookings_o();
// Check if any bookings from yesterday haven't been fulfilled // Check if any bookings from yesterday haven't been fulfilled
$bookings_o->checkUnfulfilledBookings(); try {
$bookings_o->checkUnfulfilledBookings();
} catch (Exception $e) {
// This script is invoked directly in the "node cron" container, so any
// failure here would otherwise abort the whole script with no breadcrumb.
error_log('[cron-check-unfulfilled-bookings] checkUnfulfilledBookings failed: ' . $e->getMessage());
throw $e;
}
@@ -16,7 +16,15 @@ if (!defined('WD')) {
$start = microtime(true); $start = microtime(true);
// Sync the discounts // Sync the discounts
$users_o = new users_o(); $users_o = new users_o();
$users_o->clearAllUsersEconomicCustomerDetailsFromCache(); try {
$users_o->clearAllUsersEconomicCustomerDetailsFromCache();
} catch (Exception $e) {
// Previously a failure here would silently abort the cron minute.
// Surface the breadcrumb so re-occurring failures (e.g. a broken
// e-conomic API client) become visible in container logs.
error_log('[cron-clear-econ-customer-details] failed: ' . $e->getMessage());
throw $e;
}
$end = microtime(true); $end = microtime(true);
//$slack = new \classes\slack(); //$slack = new \classes\slack();
//$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run."); //$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run.");
@@ -16,7 +16,15 @@ if (!defined('WD')) {
$start = microtime(true); $start = microtime(true);
// Sync the discounts // Sync the discounts
$users_o = new users_o(); $users_o = new users_o();
$users_o->clearAllUsersEconomicCustomerDiscountsFromCache(); try {
$users_o->clearAllUsersEconomicCustomerDiscountsFromCache();
} catch (Exception $e) {
// Previously a failure here would silently abort the cron minute.
// Surface the breadcrumb so re-occurring failures (e.g. a broken
// e-conomic API client) become visible in container logs.
error_log('[cron-clear-econ-customer-discounts] failed: ' . $e->getMessage());
throw $e;
}
$end = microtime(true); $end = microtime(true);
//$slack = new \classes\slack(); //$slack = new \classes\slack();
//$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run."); //$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run.");
+216 -62
View File
@@ -2,6 +2,7 @@
// prevent direct access // prevent direct access
use classes\backup_store; use classes\backup_store;
use classes\account_deletion_service;
use classes\economic; use classes\economic;
use classes\economic_transfer_queue; use classes\economic_transfer_queue;
use classes\invoice_period_flag_service; use classes\invoice_period_flag_service;
@@ -29,6 +30,7 @@ use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
use objects\department_lanes_o; use objects\department_lanes_o;
use objects\department_goals_o; use objects\department_goals_o;
use objects\department_selfserve_tasks_o; use objects\department_selfserve_tasks_o;
use objects\department_variables_o;
use objects\departments_o; use objects\departments_o;
use objects\bookings_o; use objects\bookings_o;
use objects\logs_o; use objects\logs_o;
@@ -63,6 +65,12 @@ $response_cron = [];
// Define the cron tasks // Define the cron tasks
$cron_tasks = [ $cron_tasks = [
'ProcessAccountDeletionRequestsCron' => [
'interval' => 300,
'last_run' => 0,
'next_run' => 0,
'function' => 'ProcessAccountDeletionRequestsCron',
],
// 'CheckUnfulfilledBookings' => [ // 'CheckUnfulfilledBookings' => [
// 'interval' => 86400, // 24 hours // 'interval' => 86400, // 24 hours
// 'last_run' => 0, // 'last_run' => 0,
@@ -192,6 +200,17 @@ $cron_tasks = [
], ],
]; ];
function ProcessAccountDeletionRequestsCron(): array
{
if (!account_deletion_service::workerEnabled()) {
return ['processed' => 0, 'completed' => 0, 'failed' => 0, 'skipped' => true];
}
$result = (new account_deletion_service())->processPending(25);
echo '[' . date('Y-m-d H:i:s') . '][CRON] Account deletion requests: '
. (int)$result['completed'] . ' completed, ' . (int)$result['failed'] . " failed.\n";
return $result;
}
function ReplicaFailoverMonitorCron(): void function ReplicaFailoverMonitorCron(): void
{ {
global $db; global $db;
@@ -281,7 +300,12 @@ function WarmInvoicePeriodAutomaticFlagsCron(): void
$key = $period['dateFrom'] . '|' . $period['dateTo']; $key = $period['dateFrom'] . '|' . $period['dateTo'];
$toWarm[$key] = $period; $toWarm[$key] = $period;
} }
} catch (Throwable) { } catch (Throwable $throwable) {
// Previously a Redis outage here would silently drop deferred periods
// from the warming queue. Surface the breadcrumb so ops can correlate
// missing invoice-period flags with Redis incidents.
warn('WarmInvoicePeriodAutomaticFlagsCron failed to consume defer queue: ' . $throwable->getMessage());
error_log('[cron-warm-invoice-period-flags] defer queue consume failed: ' . $throwable->getMessage());
} }
foreach ($toWarm as $period) { foreach ($toWarm as $period) {
@@ -381,7 +405,7 @@ function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
} }
/** /**
* @return array{id:?string,name:?string} * @return array{id: ?string, name: ?string}
*/ */
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
{ {
@@ -515,15 +539,6 @@ function normalizeWarmupTextValue(mixed $value): ?string
return null; return null;
} }
function checkUnfulfilledBookings(): void
{
// This is deactivated for now, as it is not wanted.
// I'm saving this for later, as it is a good idea to have this in place.
return;
$bookings_o = new bookings_o();
$bookings_o->checkUnfulfilledBookings();
}
function syncBookings(): void function syncBookings(): void
{ {
$bookings_o = new bookings_o(); $bookings_o = new bookings_o();
@@ -625,12 +640,20 @@ function SyncEconomicInvoiceStatus(): void
try { try {
$economic->getTasks()->runCheckErrors(); $economic->getTasks()->runCheckErrors();
} catch (Exception $e) { } catch (Exception $e) {
// Do nothing, this is automatically running // This is automatically running, but a broken e-conomic / draft
// pipeline here would otherwise stay invisible. Surface the
// breadcrumb so re-occurring failures can be correlated with
// customer-facing invoice issues.
warn('SyncEconomicInvoiceStatus runCheckErrors failed: ' . $e->getMessage());
error_log('[cron-sync-economic-invoice-status] runCheckErrors failed: ' . $e->getMessage());
} }
try { try {
$economic->getTasks()->runCheckDrafts(); $economic->getTasks()->runCheckDrafts();
} catch (Exception $e) { } catch (Exception $e) {
// Do nothing, this is automatically running // Same as above - drafts that stay in a broken state for days are
// very hard to diagnose without a log line.
warn('SyncEconomicInvoiceStatus runCheckDrafts failed: ' . $e->getMessage());
error_log('[cron-sync-economic-invoice-status] runCheckDrafts failed: ' . $e->getMessage());
} }
} }
@@ -642,7 +665,11 @@ function SyncXLVaskModuleCron(): void
$xlvask->getTasks()->runCronTasks(); $xlvask->getTasks()->runCronTasks();
} }
} catch (Exception $e) { } catch (Exception $e) {
// Do nothing, this is automatically running // This is automatically running, but a broken XL Vask cron path
// would otherwise silently stop the entire module from being
// synchronized (usage logs, vehicles, etc.). Surface the breadcrumb.
warn('SyncXLVaskModuleCron runCronTasks failed: ' . $e->getMessage());
error_log('[cron-sync-xlvask-module] runCronTasks failed: ' . $e->getMessage());
} }
} }
@@ -1515,6 +1542,7 @@ function SelfserveOpeningRelayActivationCron(): array
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now); $candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
$summary = [ $summary = [
'checked_departments' => count($candidates), 'checked_departments' => count($candidates),
'disabled_departments' => 0,
'activated_departments' => 0, 'activated_departments' => 0,
'skipped_departments' => 0, 'skipped_departments' => 0,
'failed_departments' => 0, 'failed_departments' => 0,
@@ -1526,18 +1554,45 @@ function SelfserveOpeningRelayActivationCron(): array
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
$departmentId = (int)($candidate['department_id'] ?? 0); $departmentId = (int)($candidate['department_id'] ?? 0);
$opensAt = (string)($candidate['opens_at'] ?? ''); $opensAt = (string)($candidate['opens_at'] ?? '');
$openingDate = (string)($candidate['opening_date'] ?? $now->format('Y-m-d'));
if ($departmentId <= 0 || $opensAt === '') { if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++; $summary['skipped_departments']++;
continue; continue;
} }
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now); $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $openingDate);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++; try {
$transition = department_variables_o::withSelfServeTransitionLock(
$departmentId,
static function () use ($departmentId): array {
$departmentSummary = selfserveActivateStaffedDefaultRelaysForDepartment($departmentId);
if ((int)$departmentSummary['failed'] > 0) {
return [
'department_summary' => $departmentSummary,
'department_disabled' => false,
];
}
return [
'department_summary' => $departmentSummary,
'department_disabled' => selfserveDisableDepartmentSelfServeForOpening($departmentId),
];
},
0
);
} catch (Throwable $throwable) {
$summary['failed_departments']++;
warn(
'SelfserveOpeningRelayActivationCron could not serialize transition for department '
. $departmentId
. ': '
. $throwable->getMessage()
);
continue; continue;
} }
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId); $departmentSummary = $transition['department_summary'];
$summary['activated_relays'] += (int)$departmentSummary['activated']; $summary['activated_relays'] += (int)$departmentSummary['activated'];
$summary['skipped_relays'] += (int)$departmentSummary['skipped']; $summary['skipped_relays'] += (int)$departmentSummary['skipped'];
$summary['failed_relays'] += (int)$departmentSummary['failed']; $summary['failed_relays'] += (int)$departmentSummary['failed'];
@@ -1547,6 +1602,13 @@ function SelfserveOpeningRelayActivationCron(): array
continue; continue;
} }
$departmentDisabled = (bool)$transition['department_disabled'];
if (!$departmentDisabled) {
$summary['failed_departments']++;
continue;
}
$summary['disabled_departments']++;
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey); selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
if ((int)$departmentSummary['activated'] > 0) { if ((int)$departmentSummary['activated'] > 0) {
$summary['activated_departments']++; $summary['activated_departments']++;
@@ -1556,7 +1618,8 @@ function SelfserveOpeningRelayActivationCron(): array
} }
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: " echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
. $summary['activated_relays'] . " cleaner relays activated across " . $summary['disabled_departments'] . " departments disabled self-serve, "
. $summary['activated_relays'] . " staffed-default relays activated across "
. $summary['activated_departments'] . " departments.\n"; . $summary['activated_departments'] . " departments.\n";
return $summary; return $summary;
@@ -1574,23 +1637,32 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
$startColumn = $weekday . '_start'; $startColumn = $weekday . '_start';
$endColumn = $weekday . '_end'; $endColumn = $weekday . '_end';
$previousDate = $now->modify('-1 day');
$previousWeekday = strtolower($previousDate->format('l'));
$previousStartColumn = $previousWeekday . '_start';
$previousEndColumn = $previousWeekday . '_end';
$sql = " $sql = "
SELECT SELECT
oh.department AS department_id, oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at, TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS current_opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS current_closes_at,
TIME_FORMAT(oh.`$previousStartColumn`, '%H:%i:%s') AS previous_opens_at,
TIME_FORMAT(oh.`$previousEndColumn`, '%H:%i:%s') AS previous_closes_at
FROM department_time_bookings_opening_hours oh FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department INNER JOIN department_variables dv ON dv.department_id = oh.department
INNER JOIN department_lanes dl ON dl.department = oh.department
WHERE dv.variable = 'selfserve_enabled' WHERE dv.variable = 'selfserve_enabled'
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on') AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
AND oh.`$startColumn` IS NOT NULL AND (
AND oh.`$endColumn` IS NOT NULL (oh.`$startColumn` IS NOT NULL AND oh.`$endColumn` IS NOT NULL)
AND dl.deleted_at IS NULL OR
AND COALESCE(dl.selfserve_enabled, 1) = 1 (oh.`$previousStartColumn` IS NOT NULL AND oh.`$previousEndColumn` IS NOT NULL)
AND dl.relay_machine_cleaner_id IS NOT NULL )
AND TRIM(dl.relay_machine_cleaner_id) <> '' GROUP BY
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn` oh.department,
oh.`$startColumn`,
oh.`$endColumn`,
oh.`$previousStartColumn`,
oh.`$previousEndColumn`
"; ";
$result = $db->query($sql); $result = $db->query($sql);
@@ -1598,14 +1670,33 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now
return []; return [];
} }
return array_values(array_filter( $candidates = [];
$db->fetch_all($result), foreach ($db->fetch_all($result) as $row) {
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive( $currentOpensAt = (string)($row['current_opens_at'] ?? '');
$now, $currentClosesAt = (string)($row['current_closes_at'] ?? '');
(string)($row['opens_at'] ?? ''), if (selfserveOpeningCleanerRelayWindowActive($now, $currentOpensAt, $currentClosesAt)) {
(string)($row['closes_at'] ?? '') $candidates[] = [
) 'department_id' => $row['department_id'] ?? null,
)); 'opens_at' => $currentOpensAt,
'closes_at' => $currentClosesAt,
'opening_date' => $now->format('Y-m-d'),
];
continue;
}
$previousOpensAt = (string)($row['previous_opens_at'] ?? '');
$previousClosesAt = (string)($row['previous_closes_at'] ?? '');
if (selfserveOpeningCleanerRelayOvernightCarryoverActive($now, $previousOpensAt, $previousClosesAt)) {
$candidates[] = [
'department_id' => $row['department_id'] ?? null,
'opens_at' => $previousOpensAt,
'closes_at' => $previousClosesAt,
'opening_date' => $previousDate->format('Y-m-d'),
];
}
}
return $candidates;
} }
function selfserveOpeningCleanerRelayWindowActive( function selfserveOpeningCleanerRelayWindowActive(
@@ -1630,6 +1721,24 @@ function selfserveOpeningCleanerRelayWindowActive(
return $nowSeconds >= $opensAtSeconds; return $nowSeconds >= $opensAtSeconds;
} }
function selfserveOpeningCleanerRelayOvernightCarryoverActive(
DateTimeImmutable $now,
?string $opensAt,
?string $closesAt
): bool {
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds <= $closesAtSeconds) {
return false;
}
$nowSeconds = ((int)$now->format('G') * 3600)
+ ((int)$now->format('i') * 60)
+ (int)$now->format('s');
return $nowSeconds < $closesAtSeconds;
}
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{ {
$time = trim((string)$time); $time = trim((string)$time);
@@ -1652,7 +1761,27 @@ function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
return ($hours * 3600) + ($minutes * 60) + $seconds; return ($hours * 3600) + ($minutes * 60) + $seconds;
} }
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array function selfserveDisableDepartmentSelfServeForOpening(int $departmentId): bool
{
try {
(new department_variables_o())
->selectDepartment($departmentId)
->set('selfserve_enabled', 'false');
return true;
} catch (Throwable $throwable) {
warn(
'SelfserveOpeningRelayActivationCron failed to disable self-serve for department '
. $departmentId
. ': '
. $throwable->getMessage()
);
return false;
}
}
function selfserveActivateStaffedDefaultRelaysForDepartment(int $departmentId): array
{ {
$summary = [ $summary = [
'activated' => 0, 'activated' => 0,
@@ -1668,17 +1797,7 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
continue; continue;
} }
try { if (!selfserveLaneHasAnyConfiguredStaffedDefaultRelay($departmentLane)) {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++; $summary['skipped']++;
continue; continue;
} }
@@ -1691,13 +1810,15 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
try { try {
$lane = $selfserve->lane($laneId); $lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) { $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$summary['skipped']++; $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true);
continue; });
} $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusForDepartmentOperation(true);
$lane->setMachineCleanerRelayStatusHard(true); });
$summary['activated']++; $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
$lane->setMachineRelayStatusForDepartmentOperation(true);
});
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$summary['failed']++; $summary['failed']++;
warn( warn(
@@ -1714,19 +1835,31 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId):
return $summary; return $summary;
} }
function selfserveLaneHasAnyConfiguredStaffedDefaultRelay(?object $departmentLane): bool
{
return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_program_picker_id')
|| selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id')
|| selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_id');
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id');
}
function selfserveLaneHasConfiguredRelay(?object $departmentLane, string $relayProperty): bool
{ {
if ( if (
$departmentLane === null $departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id) || !isset($departmentLane->{$relayProperty})
|| !is_object($departmentLane->relay_machine_cleaner_id) || !is_object($departmentLane->{$relayProperty})
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value') || !method_exists($departmentLane->{$relayProperty}, 'value')
) { ) {
return false; return false;
} }
try { try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value()); $relayId = trim((string)$departmentLane->{$relayProperty}->value());
} catch (Throwable) { } catch (Throwable) {
return false; return false;
} }
@@ -1734,14 +1867,35 @@ function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null'; return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
} }
function selfserveSetOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): int
{
if (!selfserveLaneHasConfiguredRelay($lane->department_lane ?? null, $relayProperty)) {
return 0;
}
try {
$callback();
return 1;
} catch (Throwable $throwable) {
warn(
'SelfserveOpeningRelayActivationCron failed to activate relay '
. $relayProperty
. ': '
. $throwable->getMessage()
);
throw $throwable;
}
}
function selfserveOpeningCleanerRelayActivationKey( function selfserveOpeningCleanerRelayActivationKey(
int $departmentId, int $departmentId,
string $opensAt, string $opensAt,
DateTimeImmutable $now string $openingDate
): string { ): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown'; $normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:' return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d') . $openingDate
. ':' . ':'
. $departmentId . $departmentId
. ':' . ':'
@@ -17,23 +17,15 @@ $start = microtime(true);
// Load the XL Vask module // Load the XL Vask module
$xlvask = new xlvask; $xlvask = new xlvask;
try { try {
// Check if the module is enabled if ($xlvask->config->enabled->isTrue() && $xlvask->config->synchronization_enabled->isTrue()) {
if ($xlvask->config->enabled->isTrue()) { $xlvask->getTasks()->runCronTasks();
// Check if synchronization is enabled
if ($xlvask->config->synchronization_enabled->isTrue()) {
$xlvask->getTasks()->runCronTasks();
// TODO: Add synchronization for:
// - usageLogs
// - vehicles
} else {
// Synchronization is not enabled, do nothing
}
} else {
// The module is not enabled, do nothing
} }
} catch (Exception $e) { } catch (Exception $e) {
// This is automatically running, so we don't need to log the error // This is automatically running, but an XL Vask module failure here
// would otherwise stop the entire module from being synchronized at
// all (usage logs, vehicles, etc.), so surface the breadcrumb.
error_log('[cron-run-xlvask-module] runCronTasks failed: ' . $e->getMessage());
} }
$end = microtime(true); $end = microtime(true);
//$slack = new \classes\slack(); //$slack = new \classes\slack();
//$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run."); //$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run.");
+9 -1
View File
@@ -16,7 +16,15 @@ if (!defined('WD')) {
$start = microtime(true); $start = microtime(true);
// Sync the bookings // Sync the bookings
$bookings_o = new bookings_o(); $bookings_o = new bookings_o();
$bookings_o->syncBookings(); try {
$bookings_o->syncBookings();
} catch (Exception $e) {
// Previously a failure here would silently abort the cron minute.
// Surface the breadcrumb so a broken WordPress upstream becomes
// visible in container logs instead of mysteriously missing bookings.
error_log('[cron-sync-bookings] syncBookings failed: ' . $e->getMessage());
throw $e;
}
$end = microtime(true); $end = microtime(true);
//$slack = new \classes\slack(); //$slack = new \classes\slack();
//$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run."); //$slack->send_message("The booking sync script has finished. It took " . round($end - $start, 2) . " seconds to run.");
@@ -19,12 +19,16 @@ $economic = new economic();
try { try {
$economic->getTasks()->runCheckErrors(); $economic->getTasks()->runCheckErrors();
} catch (Exception $e) { } catch (Exception $e) {
// This is automatically running, so we don't need to log the error // This is automatically running, but we still want a breadcrumb so
// a permanently failing e-conomic API does not stay invisible.
error_log('[cron-sync-economic-invoice-status] runCheckErrors failed: ' . $e->getMessage());
} }
try { try {
$economic->getTasks()->runCheckDrafts(); $economic->getTasks()->runCheckDrafts();
} catch (Exception $e) { } catch (Exception $e) {
// This is automatically running, so we don't need to log the error // Same as above - this is on the cron path, so failing silently
// would mean a corrupted e-conomic draft state stays undiagnosed.
error_log('[cron-sync-economic-invoice-status] runCheckDrafts failed: ' . $e->getMessage());
} }
$end = microtime(true); $end = microtime(true);
//$slack = new \classes\slack(); //$slack = new \classes\slack();
+9 -1
View File
@@ -16,4 +16,12 @@ if (!defined('WD')) {
// Sync the logs to the database // Sync the logs to the database
$logs_o = new logs_o(); $logs_o = new logs_o();
$logs_o->syncLogsToDatabase(); try {
$logs_o->syncLogsToDatabase();
} catch (Exception $e) {
// If the logs themselves cannot be persisted, the only safe fallback is
// to emit the failure to PHP's error stream so it surfaces in container
// logs. We re-throw so the cron entry still records the failure.
error_log('[cron-sync-logs] syncLogsToDatabase failed: ' . $e->getMessage());
throw $e;
}
+3 -1
View File
@@ -14,7 +14,7 @@ require_once __DIR__ . '/classes/cors_policy.php';
/** CORS */ /** CORS */
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response // OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? '')); $preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
\classes\cors_policy::emitHeaders($preflight['headers']); \classes\cors_policy::emitHeaders($preflight['headers']);
http_response_code($preflight['status']); http_response_code($preflight['status']);
@@ -184,6 +184,7 @@ spl_autoload_register(function (string $class): void {
use classes\application_write_freeze; use classes\application_write_freeze;
use classes\db; use classes\db;
use classes\replication_bootstrap_config;
use classes\replication_manager; use classes\replication_manager;
use classes\release_manager; use classes\release_manager;
use classes\redis; use classes\redis;
@@ -311,4 +312,5 @@ $load_enabled_module_routes = static function (): void {
$load_enabled_module_routes(); $load_enabled_module_routes();
// Autoload all the routes // Autoload all the routes
/** @var router $router */
$router->auto_load_routes(WD . '/routes'); $router->auto_load_routes(WD . '/routes');
@@ -0,0 +1,16 @@
<?php
return [
[
'id' => 'account.process_deletion_requests',
'legacy_name' => 'ProcessAccountDeletionRequestsCron',
'name' => 'Process account deletion requests',
'description' => 'Anonymizes requested customer and chauffeur accounts while retaining legally required records.',
'module' => 'account',
'handler' => 'ProcessAccountDeletionRequestsCron',
'schedule' => ['type' => 'interval', 'seconds' => 300],
'timeout_seconds' => 300,
'estimated_duration_ms' => 2000,
'priority' => 25,
],
];
+143
View File
@@ -2,6 +2,149 @@
This module provides Bird API integration for voice calls and number management. This module provides Bird API integration for voice calls and number management.
## Control Plane integration
The Bird module is the credential and public-webhook authority for Pleno
Control Plane. Control Plane must never receive the Bird access key. The
integration is disabled by default and has three independently protected
surfaces:
- `GET /bird/health` is an end-user-permission-protected, read-only replacement
for the outbound-call connection test.
- `/bird/control-plane/v1/*` accepts only the configured
`control_plane_token` bearer token and exposes explicitly listed read
operations. It is not a generic Bird proxy.
- `POST /bird/webhooks/notifications` accepts only Bird notifications signed
against the exact configured public HTTPS URL. It checks the replay window
and durably deduplicates both Bird request ID and signature before returning
success.
- `POST /bird/flows/evaluate` accepts a timestamp-bound HMAC in
`x-pleno-flow-timestamp` and `x-pleno-flow-signature`. It returns only
deterministic `tag`, `assign`, `snooze`, or `close` decisions from a valid
versioned policy.
Required configuration is:
- `workspaceId`: canonical workspace identifier. While migrating, an empty
value falls back to legacy `workplaceId`.
- `allowed_channel_ids_json`: canonical JSON array of explicitly approved
channel IDs. Its empty default falls back to legacy `channelId`; malformed
or non-empty invalid configuration fails closed.
- `control_plane_enabled=false` and secret `control_plane_token`.
- secret `webhook_signing_key`, exact `webhook_public_url`, and
`webhook_replay_window_seconds=300`.
- `flow_enabled=false`, secret `flow_shared_secret`, and a valid
`flow_policy_json` document.
- `operations_actions_enabled=false` for confirmed typed voice actions.
- `outbound_messages_enabled=false`, `participantId`, and an immutable
`template_policy_json` allowlist for confirmed conversation replies.
The gateway intentionally does not expose conversation creation, number
deletion, physical gate actions, arbitrary recipients, attachments, or a
generic proxy. Outbound references are durably reserved before a provider
request; an ambiguous outcome must be inspected through
`/bird/control-plane/v1/messages/by-reference` and is never blindly retried.
Credentials and write switches can only be changed by the guarded CLI
activation command, never by a web request.
### Schema deployment and preflight
The durable webhook and outbound-action ledgers use checked-in schema version
`1`. Schema changes are never run from a web request, worker, or core API
startup. Use these commands for deployment preflight and recovery:
```bash
php scripts/bird-control-plane-schema.php check
php scripts/bird-control-plane-schema.php apply --yes
php scripts/bird-control-plane-schema.php check
```
`apply` is CLI-only and requires the explicit `--yes` guard. The status endpoint
publishes read-only `schema: {ready, version, expectedVersion, missing}` state.
Ledger-dependent reads, webhook ingestion, messages, and operational actions
fail closed with HTTP `503` and code `bird_schema_not_ready` until the preflight
is ready. Explicit activation applies and verifies the schema before enabling
any Bird write switch.
### Fail-closed production activation
Bird activation is deliberately separate from PHP-FPM and nginx startup, so a
Bird provider or configuration failure cannot make the core API unavailable.
Run the guarded activation explicitly in the deployed API container:
```bash
php /var/www/html/scripts/bird-control-plane-auto-activate.php
```
The command first transactionally disables bootstrap readiness and all three
write switches. It then canonicalizes the existing legacy workspace/channel
configuration, applies and checks schema version `1`, validates configured
channels and conversations, resolves the access-key participant, pins the
public webhook URL to
`https://api.truckwash.io:4433/bird/webhooks/notifications`, and reconciles and
verifies webhooks while writes remain dark. Only then does one transaction
enable bootstrap readiness, the Control Plane, outbound messaging, and
operational-action switches, followed by final readiness checks.
Any failure returns a non-zero exit code and re-disables the Bird capabilities
without stopping or restarting the core API.
It preserves existing valid credentials; otherwise it generates distinct
48-byte random Control Plane and webhook secrets inside the container. The
webhook key remains backend-only.
The Control Plane token is sealed with the committed RSA-3072 public key using
RSA-OAEP-SHA256 with MGF1-SHA256. OpenSSL receives plaintext only over child
stdin, never argv. Only the ciphertext, `RSA-OAEP-256` algorithm identifier,
SPKI SHA-256 fingerprint
`6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21`,
monotonic token version, and UTC update timestamp are bootstrap-visible.
Existing ciphertext is retained only when its internal token hash and all
metadata remain valid, so normal deployments do not rotate a working token.
Webhook subscription reconciliation runs before service readiness. The
reconciler first requires Bird's `available-webhooks` response to advertise the
`conversations` service, both `conversation.created` and
`conversation.updated`, and `channelId` filtering for each. It prefers the
documented organization/workspace list after deriving a single UUID-like
organization ID from workspace-consistent channel/conversation metadata.
When no organization ID is discoverable, the read-only preflight may probe the
existing workspace-scoped subscription list endpoint. That fallback is
accepted only when it returns an explicit `results`, `items`, or `data`
collection with bounded pagination. Any unsupported response, ambiguous page,
or provider error becomes `organization_id_required` before a POST or PATCH.
The reconciler creates one exact subscription for each event and allowlisted
channel, or patches only an existing subscription with the exact Pleno URL,
event, and sole channel filter. It never deletes or mutates unrelated
subscriptions.
Any schema, provider, encryption, subscription, or final readiness failure
transactionally restores bootstrap readiness and all three write switches to
false, then exits container startup before PHP-FPM/nginx starts. Flow and
template automation remain disabled unless their existing versioned policies
are explicit, non-empty, and structurally valid; Flow also requires an existing
strong shared secret.
### Local Control Plane credential bootstrap
The unauthenticated `GET /bird/control-plane/v1/bootstrap` response is
`Cache-Control: no-store` and contains only the fixed validated ciphertext
envelope. Unavailable state always returns the same `404 Not found` response.
The endpoint never returns plaintext, hashes, provider credentials, or the
backend-only webhook key.
On the Control Plane host, run:
```bash
scripts/bird-control-plane-bootstrap-local.sh
```
The script is pinned to `https://api.truckwash.io:4433` and accepts no URL
override. It validates the exact algorithm, public-key fingerprint, fixed
ciphertext shape, version, and timestamp, decrypts using the local `0600`
private key, and writes only a temporary `0600` candidate. It immediately proves
the bearer against the authenticated status endpoint and atomically retains it
as `/home/jeppe/.openclaw/credentials/bird.gateway-token` only after success.
## Endpoints ## Endpoints
### Voice calls ### Voice calls
@@ -7,13 +7,41 @@ require_once WD . '/modules/bird/config/bird_enabled_c.php';
require_once WD . '/modules/bird/config/bird_api_key_c.php'; require_once WD . '/modules/bird/config/bird_api_key_c.php';
require_once WD . '/modules/bird/config/bird_server_url_c.php'; require_once WD . '/modules/bird/config/bird_server_url_c.php';
require_once WD . '/modules/bird/config/bird_workplaceId_c.php'; require_once WD . '/modules/bird/config/bird_workplaceId_c.php';
require_once WD . '/modules/bird/config/bird_workspaceId_c.php';
require_once WD . '/modules/bird/config/bird_channelId_c.php'; require_once WD . '/modules/bird/config/bird_channelId_c.php';
require_once WD . '/modules/bird/config/bird_allowed_channel_ids_json_c.php';
require_once WD . '/modules/bird/config/bird_control_plane_enabled_c.php';
require_once WD . '/modules/bird/config/bird_control_plane_token_c.php';
require_once WD . '/modules/bird/config/bird_webhook_signing_key_c.php';
require_once WD . '/modules/bird/config/bird_webhook_public_url_c.php';
require_once WD . '/modules/bird/config/bird_webhook_replay_window_seconds_c.php';
require_once WD . '/modules/bird/config/bird_flow_enabled_c.php';
require_once WD . '/modules/bird/config/bird_flow_shared_secret_c.php';
require_once WD . '/modules/bird/config/bird_flow_policy_json_c.php';
require_once WD . '/modules/bird/config/bird_operations_actions_enabled_c.php';
require_once WD . '/modules/bird/config/bird_outbound_messages_enabled_c.php';
require_once WD . '/modules/bird/config/bird_template_policy_json_c.php';
require_once WD . '/modules/bird/config/bird_participantId_c.php';
use bird\config\bird_enabled_c; use bird\config\bird_enabled_c;
use bird\config\bird_api_key_c; use bird\config\bird_api_key_c;
use bird\config\bird_server_url_c; use bird\config\bird_server_url_c;
use bird\config\bird_workplaceId_c; use bird\config\bird_workplaceId_c;
use bird\config\bird_workspaceId_c;
use bird\config\bird_channelId_c; use bird\config\bird_channelId_c;
use bird\config\bird_allowed_channel_ids_json_c;
use bird\config\bird_control_plane_enabled_c;
use bird\config\bird_control_plane_token_c;
use bird\config\bird_webhook_signing_key_c;
use bird\config\bird_webhook_public_url_c;
use bird\config\bird_webhook_replay_window_seconds_c;
use bird\config\bird_flow_enabled_c;
use bird\config\bird_flow_shared_secret_c;
use bird\config\bird_flow_policy_json_c;
use bird\config\bird_operations_actions_enabled_c;
use bird\config\bird_outbound_messages_enabled_c;
use bird\config\bird_template_policy_json_c;
use bird\config\bird_participantId_c;
use traits\module_config_t; use traits\module_config_t;
class bird_c class bird_c
@@ -44,11 +72,31 @@ class bird_c
*/ */
public bird_workplaceId_c $workplaceId; public bird_workplaceId_c $workplaceId;
/**
* Canonical Bird workspace identifier.
* @var bird_workspaceId_c
*/
public bird_workspaceId_c $workspaceId;
/** /**
* Default Bird channel identifier * Default Bird channel identifier
* @var bird_channelId_c * @var bird_channelId_c
*/ */
public bird_channelId_c $channelId; public bird_channelId_c $channelId;
public bird_allowed_channel_ids_json_c $allowed_channel_ids_json;
public bird_control_plane_enabled_c $control_plane_enabled;
public bird_control_plane_token_c $control_plane_token;
public bird_webhook_signing_key_c $webhook_signing_key;
public bird_webhook_public_url_c $webhook_public_url;
public bird_webhook_replay_window_seconds_c $webhook_replay_window_seconds;
public bird_flow_enabled_c $flow_enabled;
public bird_flow_shared_secret_c $flow_shared_secret;
public bird_flow_policy_json_c $flow_policy_json;
public bird_operations_actions_enabled_c $operations_actions_enabled;
public bird_outbound_messages_enabled_c $outbound_messages_enabled;
public bird_template_policy_json_c $template_policy_json;
public bird_participantId_c $participantId;
public function __construct() public function __construct()
{ {
@@ -58,12 +106,40 @@ class bird_c
bird_api_key_c::class, bird_api_key_c::class,
bird_server_url_c::class, bird_server_url_c::class,
bird_workplaceId_c::class, bird_workplaceId_c::class,
bird_workspaceId_c::class,
bird_channelId_c::class, bird_channelId_c::class,
bird_allowed_channel_ids_json_c::class,
bird_control_plane_enabled_c::class,
bird_control_plane_token_c::class,
bird_webhook_signing_key_c::class,
bird_webhook_public_url_c::class,
bird_webhook_replay_window_seconds_c::class,
bird_flow_enabled_c::class,
bird_flow_shared_secret_c::class,
bird_flow_policy_json_c::class,
bird_operations_actions_enabled_c::class,
bird_outbound_messages_enabled_c::class,
bird_template_policy_json_c::class,
bird_participantId_c::class,
]); ]);
$this->enabled = new bird_enabled_c(); $this->enabled = new bird_enabled_c();
$this->api_key = new bird_api_key_c(); $this->api_key = new bird_api_key_c();
$this->server_url = new bird_server_url_c(); $this->server_url = new bird_server_url_c();
$this->workplaceId = new bird_workplaceId_c(); $this->workplaceId = new bird_workplaceId_c();
$this->workspaceId = new bird_workspaceId_c();
$this->channelId = new bird_channelId_c(); $this->channelId = new bird_channelId_c();
$this->allowed_channel_ids_json = new bird_allowed_channel_ids_json_c();
$this->control_plane_enabled = new bird_control_plane_enabled_c();
$this->control_plane_token = new bird_control_plane_token_c();
$this->webhook_signing_key = new bird_webhook_signing_key_c();
$this->webhook_public_url = new bird_webhook_public_url_c();
$this->webhook_replay_window_seconds = new bird_webhook_replay_window_seconds_c();
$this->flow_enabled = new bird_flow_enabled_c();
$this->flow_shared_secret = new bird_flow_shared_secret_c();
$this->flow_policy_json = new bird_flow_policy_json_c();
$this->operations_actions_enabled = new bird_operations_actions_enabled_c();
$this->outbound_messages_enabled = new bird_outbound_messages_enabled_c();
$this->template_policy_json = new bird_template_policy_json_c();
$this->participantId = new bird_participantId_c();
} }
} }

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