Compare commits

...
3 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
8 changed files with 78 additions and 4 deletions
+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>
@@ -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"
) )
+8
View File
@@ -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({
@@ -1495,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.",
@@ -1514,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.",
}; };
} }
@@ -1541,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 => [],
+1 -1
View File
@@ -237,7 +237,7 @@ class ordersRoute
$order->setPendingHandheldIndicator(); $order->setPendingHandheldIndicator();
} }
// Log the incident // Log the incident
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . $data['department_id'] . ')'); (new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . (int)$order->id . ')');
// Return a success message, containing the orders array // Return a success message, containing the orders array
$response->success($order->asArray()); $response->success($order->asArray());
} else { } else {
@@ -167,7 +167,8 @@ it('builds interactive message parts for order and wash certificate warnings', f
['type' => 'text', 'text' => ' is present without a wash certificate.'], ['type' => 'text', 'text' => ' is present without a wash certificate.'],
]); ]);
expect($xlVaskFlag['message_parts'])->toBe([ expect($xlVaskFlag['message_parts'])->toBe([
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], ['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => 'wash-55'],
['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.'],
]); ]);
}); });
@@ -0,0 +1,17 @@
<?php
it('logs the newly created order id (not the department id) when POST /orders succeeds', function (): void {
$routeFile = app_path('routes/ordersRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
// The POST /orders handler must log the id of the order that was just
// persisted by addArray(), not the department id from the request body.
// Without this, the audit trail for the "check-in creates 0-orders" bug
// is useless — every successful create logs the wrong identifier.
expect($content)->toContain("'Successfully added an order (ID: ' . (int)\$order->id . ')'");
// Guard against the previous copy/paste regression reappearing.
expect($content)->not->toContain("'Successfully added an order (ID: ' . \$data['department_id'] . ')'");
});