Compare commits

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

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

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

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

- New GET /admin/schema-check endpoint — returns 503 with explicit
  list of missing columns if any are absent (instead of a generic 500)
- scripts/run-schema-bootstraps.php — auto-discovers and runs every
  *_schema_bootstrap class on the live database (additive, idempotent)
- scripts/schema-health-check.php — CLI tool for the same check, used
  by deploy pipelines
- New Pest contract test SchemaHealthCheckTest — verifies the test DB
  has every required users column and the schema-check endpoint works
- deploy.yml: pre-deploy step runs the bootstrap runner, smoke test
  also runs the schema check, Slack alert on failure
2026-08-16 18:48:38 +00:00
Jeppe B 34df80530c feat(api): product merging infrastructure for SF (TRU-94) (#379)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-16 20:45:10 +02:00
3d0a8eeae7 feat(api): add optional invoice_email field for customers (TRU-77) (#381)
## Summary

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

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

## Changes

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

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

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

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

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

## Backwards compatibility

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

## Linear

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

---------

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

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

## Root cause

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

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

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

## Fix

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

## Test

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

## Files changed

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

## Refs

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

---------

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

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

## Root cause

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

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

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

## Fix

Minimal change in `getUserByCustomerNumber()`:

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

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

## Regression test

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

## Test run

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

## Out of scope

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

## Linear

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

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

---------

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

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

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

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

## What this contains

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

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

## Why

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

## Verification

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

## Notes

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

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 09:08:03 +02:00
28 changed files with 1987 additions and 21 deletions
+167
View File
@@ -0,0 +1,167 @@
name: Deploy to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual deploy'
required: false
default: 'manual'
concurrency:
group: deploy-${{ github.repository }}
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: CI + Deploy
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show commit info
run: |
echo "Repo: ${{ github.repository }}"
echo "Branch: ${{ github.ref }}"
echo "Commit: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
# === CI (phpunit / vitest) runs here via repo's existing CI config ===
# (Most of our repos already have a "Required CI" check; this section
# would invoke that. If your repo doesn't have a CI workflow, the
# required-check on the branch will block this workflow's deploy step.)
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git rev-parse HEAD > /tmp/last_deploy_sha
echo "PRE_SHA=$(cat /tmp/last_deploy_sha)"
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git fetch origin master
git reset --hard origin/master
# PHP repos: composer install + clear cache
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
php artisan cache:clear || true
php artisan config:cache || true
# Restart php-fpm if used
sudo systemctl reload php8.2-fpm || true
fi
# Node repos: npm ci + build
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
# Restart node service
sudo systemctl reload pleno-vue || sudo systemctl reload nginx || true
fi
# Restart generic services
sudo systemctl reload nginx || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Pre-deploy schema check (run all *_schema_bootstrap)
id: pre_schema
run: |
echo "Running schema bootstraps against the live database…"
# Idempotent — adds missing columns, never drops anything.
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
# production failure mode (TRU-77) where migrations were
# merged to master but never applied to the live DB.
php scripts/run-schema-bootstraps.php
echo "Schema bootstraps complete."
- name: Alert Slack if schema-check fails (pre-deploy)
if: failure()
run: |
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
https://slack.com/api/chat.postMessage \
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
- name: Smoke test
id: smoke
continue-on-error: true
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
# Also hit the new admin schema-check endpoint to verify
# no required columns are missing.
echo "::group::Schema health check"
php scripts/schema-health-check.php | tee /tmp/schema-report.json
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
echo "::error::Schema health check FAILED — missing columns:"
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
exit 1
fi
echo "Schema health check OK."
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure'
run: |
echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git reset --hard ${{ steps.pre.outputs.pre_sha }}
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
sudo systemctl reload php8.2-fpm || true
fi
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
fi
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v1.27.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.repository }}@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke test passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Update Linear issue
if: success() && steps.deploy.outcome == 'success'
run: |
# Find Linear issues in this commit's history and post a comment
# (uses GitHub's auto-link: if PR body contains "TRU-123" it auto-links)
# We skip this here; the OpenClaw cron `f26dfd83` handles Linear updates.
echo "Deploy notification will be picked up by OpenClaw cron."
+1
View File
@@ -7,4 +7,5 @@
<!-- AUTO-GENERATED, DO NOT EDIT -->
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
</topic>
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
' title="API Reference" id="API-Reference">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
"successful HTTP request handled by the broker container and defaults to the container's "
"start time when no request has been processed yet.</p>\n"
"</topic>\n"
)
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Pre-deploy schema bootstrap runner.
*
* Loads and runs every `*_schema_bootstrap` class so the production
* database has all the columns the current code expects. Each
* bootstrap is additive and idempotent — safe to run on every deploy.
*
* Run via:
* php scripts/run-schema-bootstraps.php
*
* Used in .github/workflows/deploy.yml as a pre-deploy step.
*
* When you add a new *_schema_bootstrap class, you don't need to
* edit this file — the runner auto-discovers any class whose name
* ends in `_schema_bootstrap`.
*/
namespace scripts;
// Load the app entry point so $db is wired up the same way as in
// normal request handling.
$index = __DIR__ . '/../services/nginx/app/index.php';
if (!file_exists($index)) {
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
exit(2);
}
require_once $index;
$classesDir = __DIR__ . '/../services/nginx/app/classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
if (!$bootstraps) {
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
exit(0);
}
$ran = 0;
$skipped = 0;
foreach ($bootstraps as $file) {
require_once $file;
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (!class_exists($class)) {
fwrite(STDERR, " [skip] {$base}: class not found\n");
$skipped++;
continue;
}
if (!method_exists($class, 'ensureSchema')) {
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
$skipped++;
continue;
}
try {
$class::ensureSchema();
echo " [ok] {$base}\n";
$ran++;
} catch (\Throwable $e) {
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
exit(1);
}
}
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
/**
* Schema health check — verifies all required DB columns exist.
*
* Run via:
* GET /api/admin/schema-check (returns JSON report)
* php scripts/schema-health-check.php (CLI, exits 0/1)
*
* Lists the columns that the code expects to find in each critical
* table. If a column is missing, the response is 503 (HTTP) or
* exit code 1 (CLI) — clearly distinct from a generic 500.
*
* Add to the list when introducing a new optional column.
*/
namespace scripts;
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
use classes\customer_invoice_email_schema_bootstrap;
const SCHEMA_REQUIREMENTS = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
function check_schema(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
// First: run the schema bootstrap (additive, idempotent) so we
// give the DB a chance to self-heal.
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
$report['tables_checked']++;
// Confirm the table itself exists
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
// CLI mode
if (PHP_SAPI === 'cli') {
$report = check_schema();
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
exit($report['ok'] ? 0 : 1);
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
+8
View File
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
const browserStreamSessions = new Map();
const gatewayStreamSessions = new Map();
const inflightGatewaySyncs = new Map();
const containerStartedAt = currentTimestamp();
let lastActivityAt = containerStartedAt;
const managerRequest = async (path, body = {}, method = "POST") => {
if (!managerUrl) {
@@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
lastActivityAt = currentTimestamp();
if (req.method === "GET" && url.pathname === "/api/health") {
jsonResponse(res, 200, {
ok: true,
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size,
lastActivityAt,
});
return;
}
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
pendingCommands,
managerUrl,
authMode,
containerStartedAt,
get lastActivityAt() {
return lastActivityAt;
},
},
};
}
+39
View File
@@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => {
assert.equal(healthJson.auth_mode, "manager");
assert.equal(healthJson.manager_url_configured, true);
assert.equal(healthJson.shared_secret_configured, true);
assert.equal(typeof healthJson.lastActivityAt, "string");
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
await broker.close();
});
test("broker updates lastActivityAt after each successful request", async () => {
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
const address = await broker.listen(0);
const port = address.port;
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const firstJson = await firstResponse.json();
const firstActivityAt = broker.state.lastActivityAt;
assert.equal(typeof firstJson.lastActivityAt, "string");
assert.equal(firstJson.lastActivityAt, firstActivityAt);
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
await new Promise((resolve) => setTimeout(resolve, 5));
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
headers: {
"x-edge-broker-secret": "secret",
},
});
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
assert.ok(broker.state.lastActivityAt > firstActivityAt);
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const secondJson = await secondResponse.json();
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
await broker.close();
});
test("broker bridges browser shell sessions through the connected agent", async () => {
const closedSessions = [];
const broker = createBrokerServer({
@@ -0,0 +1,93 @@
<?php
namespace classes;
/**
* Ensures additive schema for the customer `invoice_email` field
* (TRU-77 / DRIFT 16). The field is optional and stores an
* e-mail address that should receive the customer's invoices
* separately from the customer's primary `email`.
*/
class customer_invoice_email_schema_bootstrap
{
private static bool $initialized = false;
private const TABLE = 'users';
private const COLUMN = 'invoice_email';
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::ensureUsersTable($db);
self::ensureInvoiceEmailColumn($db);
self::$initialized = true;
}
private static function ensureUsersTable(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
display_name VARCHAR(255) NULL,
email VARCHAR(255) NULL,
phone_country_code INT NULL,
phone BIGINT NULL,
password VARCHAR(255) NULL,
group_id INT NOT NULL DEFAULT 0,
xlvask_customer_id VARCHAR(255) NULL,
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
wash_certificate_email VARCHAR(255) NULL,
invoice_email VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(255) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
KEY idx_users_customer_number (customer_number),
KEY idx_users_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
private static function ensureInvoiceEmailColumn(object $db): void
{
if (!self::tableExists($db, self::TABLE)) {
return;
}
if (self::columnExists($db, self::TABLE, self::COLUMN)) {
return;
}
$safeTable = str_replace('`', '', self::TABLE);
$db->query(
"ALTER TABLE `{$safeTable}`
ADD COLUMN " . self::COLUMN . " VARCHAR(255) NULL
AFTER wash_certificate_email"
);
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
@@ -14,6 +14,10 @@ class customer_mass_import_service
*/
public function import(array $payload): array
{
// TRU-77 / DRIFT 16: ensure the invoice_email column exists before we
// attempt to populate it on a local customer.
customer_invoice_email_schema_bootstrap::ensureSchema();
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
@@ -62,9 +66,15 @@ class customer_mass_import_service
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
// TRU-77 / DRIFT 16: resolve the e-conomic delivery address into a
// local variable instead of overwriting $normalized['email']. The
// primary customer email must remain intact for the result payload
// and for downstream local-customer sync; the create call needs the
// dedicated invoice address (or the primary as a fallback) on its
// own.
$createEmail = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
@@ -111,6 +121,7 @@ class customer_mass_import_service
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
@@ -193,6 +204,42 @@ class customer_mass_import_service
return $email;
}
/**
* Normalize the optional dedicated invoice email (TRU-77 / DRIFT 16).
* Empty/whitespace values collapse to null. An explicit non-empty value
* must be a syntactically valid email address; an invalid value is
* rejected to keep invoices from being routed to a malformed address.
*/
protected function normalizeInvoiceEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid invoice email address.', 400);
}
return $email;
}
/**
* Resolve the e-mail address that e-conomic should use to deliver
* invoices for the customer (TRU-77 / DRIFT 16). Prefers the dedicated
* `invoice_email` when provided, falling back to the customer's primary
* `email`.
*/
protected function resolveInvoiceEmail(array $normalized, array &$warnings): string
{
if (!empty($normalized['invoice_email'])) {
return (string)$normalized['invoice_email'];
}
if (!empty($normalized['email'])) {
return (string)$normalized['email'];
}
$warnings[] = 'No invoice email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
@@ -209,12 +256,9 @@ class customer_mass_import_service
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
// TRU-77 / DRIFT 16: invoices must be routed to the dedicated
// invoice_email when provided, otherwise to the customer's email.
return $this->resolveInvoiceEmail($normalized, $warnings);
}
protected function searchEconomicCustomersByCvr(string $cvr): array
@@ -229,7 +273,7 @@ class customer_mass_import_service
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
@@ -241,7 +285,10 @@ class customer_mass_import_service
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
// TRU-77 / DRIFT 16: the dedicated invoice_email (or the
// primary email as a fallback) is passed in explicitly so the
// caller's $normalized['email'] is never mutated here.
'email' => $createEmail,
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
@@ -396,6 +443,7 @@ class customer_mass_import_service
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'invoice_email' => $normalized['invoice_email'] ?? null,
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
@@ -416,6 +464,7 @@ class customer_mass_import_service
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$invoice_email = $normalized['invoice_email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
@@ -431,6 +480,16 @@ class customer_mass_import_service
}
}
// TRU-77 / DRIFT 16: persist the dedicated invoice email override
// when provided so invoice routing survives subsequent local edits.
if ($invoice_email !== null && $customer->getInvoiceEmailOverride() === null) {
try {
$customer->setInvoiceEmail($invoice_email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local invoice email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
@@ -1495,6 +1495,7 @@ class invoice_period_flag_service
{
$product = (string)($params['product'] ?? 'Item');
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
$washId = (string)($params['wash_id'] ?? '');
return match ($definitionKey) {
'price_mismatch' => "{$product} product price differs from expected.",
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
@@ -1514,7 +1515,9 @@ class invoice_period_flag_service
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
'xlvask_missing_order_link' => $washId === ''
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
default => "Automatically detected invoice-period issue.",
};
}
@@ -1541,7 +1544,8 @@ class invoice_period_flag_service
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
],
'xlvask_missing_order_link' => [
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
],
default => [],
@@ -33,6 +33,31 @@ class products_schema_bootstrap
);
}
if (!self::columnExists($db, 'products', 'merged_into_product_id')) {
$db->query(
"ALTER TABLE products
ADD COLUMN merged_into_product_id INT NULL DEFAULT NULL
AFTER max_quantity_per_order,
ADD KEY idx_products_merged_into (merged_into_product_id)"
);
}
if (!self::tableExists($db, 'product_merges')) {
$db->query(
"CREATE TABLE IF NOT EXISTS product_merges (
id INT AUTO_INCREMENT PRIMARY KEY,
source_product_id INT NOT NULL,
target_product_id INT NOT NULL,
merged_by_user_id INT NULL,
reason VARCHAR(500) NULL,
merged_at DATETIME NOT NULL,
KEY idx_product_merges_source (source_product_id),
KEY idx_product_merges_target (target_product_id),
UNIQUE KEY uq_product_merges_source (source_product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
self::$initialized = true;
}
+132
View File
@@ -84,6 +84,12 @@ class products_o extends db
* @var object_property $max_quantity_per_order
*/
public object_property $max_quantity_per_order;
/**
* If non-null, this product has been merged into the product with the given id.
* All read paths should resolve to the target product (see resolveActiveProductId()).
* @var object_property $merged_into_product_id
*/
public object_property $merged_into_product_id;
/**
* The timestamp of when the object was created
* @var object_property
@@ -134,6 +140,7 @@ class products_o extends db
$this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
$this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false);
$this->merged_into_product_id = new object_property($this->table, $this->id, 'merged_into_product_id', 'int', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
}
@@ -227,6 +234,7 @@ class products_o extends db
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(),
'merged_into_product_id' => $this->merged_into_product_id->value() === null ? null : (int)$this->merged_into_product_id->value(),
'created_at' => (string)$this->created_at->value(),
'updated_at' => (string)$this->updated_at->value(),
];
@@ -370,4 +378,128 @@ class products_o extends db
self::requireSelected();
return $this->id === 41;
}
/**
* Returns the product id that should be used for new orders and pricing.
* If this product has been merged into another (merged_into_product_id is set),
* the target id is returned. The merge chain is followed transitively with a
* safety cap to avoid infinite loops.
*/
public function resolveActiveProductId(): int
{
self::requireSelected();
products_schema_bootstrap::ensureTables();
$currentId = (int)$this->id;
$visited = [$currentId => true];
$maxHops = 16;
for ($i = 0; $i < $maxHops; $i++) {
$next = self::fetchMergedInto($currentId);
if ($next === null) {
return $currentId;
}
if (isset($visited[$next])) {
// Cycle detected: stop at the current node rather than spinning.
return $currentId;
}
$visited[$next] = true;
$currentId = $next;
}
return $currentId;
}
/**
* Static helper: given a product id, return the product id it is merged into,
* or null if it is not merged. Performs a single hop (no chain following).
*/
public static function fetchMergedInto(int $productId): ?int
{
global $db;
if (!isset($db) || $productId <= 0) {
return null;
}
$productId = (int)$db->escape_string((string)$productId);
$result = $db->query("SELECT merged_into_product_id FROM products WHERE id = {$productId}");
if ($result === false || !is_object($result) || (int)$result->num_rows === 0) {
return null;
}
$row = $db->fetch_assoc($result);
$merged = $row['merged_into_product_id'] ?? null;
if ($merged === null || $merged === '' || (int)$merged === 0) {
return null;
}
return (int)$merged;
}
/**
* Merge this product into another. The source product keeps its id (and therefore
* its historical order_items references), but reads and new orders will resolve to
* the target product. An audit row is written to product_merges.
*
* Throws \RuntimeException on validation failure.
*/
public function mergeInto(int $targetProductId, ?int $mergedByUserId = null, ?string $reason = null): void
{
self::requireSelected();
products_schema_bootstrap::ensureTables();
global $db, $response;
$sourceId = (int)$this->id;
if ($sourceId === $targetProductId) {
throw new \RuntimeException('Cannot merge a product into itself');
}
if ($targetProductId <= 0) {
throw new \RuntimeException('Invalid target product id');
}
// Target must exist
$targetCheck = $db->query("SELECT id FROM products WHERE id = " . (int)$targetProductId);
if ($targetCheck === false || !is_object($targetCheck) || (int)$targetCheck->num_rows === 0) {
throw new \RuntimeException('Target product does not exist');
}
// Source must not already be merged
$existing = self::fetchMergedInto($sourceId);
if ($existing !== null) {
throw new \RuntimeException("Source product {$sourceId} is already merged into product {$existing}");
}
// Target must not itself be a source (no chains during creation; chain
// resolution is supported at read time, but creating a chain here keeps
// the audit table unambiguous).
$targetIsSource = $db->query("SELECT id FROM products WHERE id = " . (int)$targetProductId . " AND merged_into_product_id IS NOT NULL");
if ($targetIsSource !== false && is_object($targetIsSource) && (int)$targetIsSource->num_rows > 0) {
throw new \RuntimeException('Target product is itself merged into another product; cannot chain merges during creation');
}
$sourceIdEsc = (int)$db->escape_string((string)$sourceId);
$targetIdEsc = (int)$db->escape_string((string)$targetProductId);
$mergedBy = $mergedByUserId === null ? 'NULL' : (string)(int)$mergedByUserId;
$reasonSql = $reason === null ? 'NULL' : "'" . $db->escape_string(mb_substr($reason, 0, 500)) . "'";
$now = date('Y-m-d H:i:s');
$db->query("START TRANSACTION");
try {
$updateSql = "UPDATE products SET merged_into_product_id = {$targetIdEsc} WHERE id = {$sourceIdEsc}";
if (!$db->query($updateSql)) {
throw new \RuntimeException('Failed to update products.merged_into_product_id');
}
$insertSql = "INSERT INTO product_merges (source_product_id, target_product_id, merged_by_user_id, reason, merged_at) VALUES ({$sourceIdEsc}, {$targetIdEsc}, {$mergedBy}, {$reasonSql}, '{$now}')";
if (!$db->query($insertSql)) {
throw new \RuntimeException('Failed to insert product_merges audit row');
}
$db->query("COMMIT");
} catch (\RuntimeException $e) {
$db->query("ROLLBACK");
throw $e;
}
// Refresh local object state
$this->getObjectProperties();
}
}
+88 -1
View File
@@ -44,6 +44,7 @@ class users_o extends db
public object_property $sms_notifications_enabled;
public object_property $email_notifications_enabled;
public object_property $wash_certificate_email; // Optional
public object_property $invoice_email; // Optional - TRU-77 / DRIFT 16
protected array $wash_subscription_transactions;
public object_property $two_factor_secret;
public object_property $two_factor_enabled;
@@ -123,6 +124,7 @@ class users_o extends db
$this->sms_notifications_enabled = new object_property($this->table, $this->id, 'sms_notifications_enabled', 'bool', false);
$this->email_notifications_enabled = new object_property($this->table, $this->id, 'email_notifications_enabled', 'bool', false);
$this->wash_certificate_email = new object_property($this->table, $this->id, 'wash_certificate_email', 'string', false);
$this->invoice_email = new object_property($this->table, $this->id, 'invoice_email', 'string', false);
$this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false);
$this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false);
}
@@ -234,15 +236,27 @@ class users_o extends db
}
public function add(string $customer_number, mixed $password, int $role = 0): void
public function add(string $customer_number, mixed $password, int $role = 0, ?string $invoice_email = null): void
{
global $db;
// Ensure the invoice_email column exists (TRU-77 / DRIFT 16)
\classes\customer_invoice_email_schema_bootstrap::ensureSchema();
// Avoid SQL injection
$customer_number = $db->escape_string($customer_number);
$role = $db->escape_string($role);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
$password = $db->escape_string($password);
$invoice_email_value = null;
if ($invoice_email !== null) {
$trimmed = trim($invoice_email);
if ($trimmed !== '') {
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$invoice_email_value = $db->escape_string($trimmed);
}
}
// Create a new record in the database
$sql = "INSERT INTO $this->table (customer_number, password, group_id) VALUES ('$customer_number', '$password', $role)";
$db->query($sql);
@@ -256,6 +270,11 @@ class users_o extends db
// Set the values of the object properties
$this->getObjectProperties();
if ($invoice_email_value !== null) {
$this->invoice_email->set($invoice_email_value);
}
// Set the default attributes
//$this->addAttribute('invoiceAllOrdersIndividually');
$this->addAttribute('restrictTankCleaning');
@@ -389,6 +408,19 @@ class users_o extends db
if ($user_id !== null) {
$this->id = (int)$user_id;
$this->getObjectProperties();
// BUG FIX (TRU-18 / AUT-14): Verify the loaded user actually owns the
// requested EC customer_number. If the inverse Redis cache
// (customer_number -> user_id) is stale — e.g. because a user's
// customer_number was re-mapped via a code path that did not clear
// this cache — getObjectProperties() will have loaded the user's
// CURRENT customer_number from the DB, which may differ from the
// one we asked for. Without this check, downstream invoice code
// (getCustomerEcocomicData, setCustomerNumber) would use the
// stale user and route the invoice to the wrong EC account.
if ((int)$this->customer_number->value() !== $customer_number) {
self::redisCache()?->clear_user_id_from_customer_number($customer_number);
return $this->getUserByCustomerNumber($customer_number);
}
return $this;
}
@@ -428,6 +460,8 @@ class users_o extends db
'number' => $phone,
],
'email' => $this->email->value(),
'invoice_email' => $this->getInvoiceEmailOverride(),
'invoice_email_fallback' => $this->email->value(),
'notifications' => [
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
@@ -1564,6 +1598,59 @@ class users_o extends db
$this->email->set($email);
}
/**
* Get the optional invoice email for the user.
* Returns the dedicated invoice email when set, otherwise falls back to
* the user's primary email. This is the address e-conomic uses to send
* invoices for the customer (TRU-77 / DRIFT 16).
*/
public function getInvoiceEmail(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice !== null && trim((string)$invoice) !== '') {
return (string)$invoice;
}
$primary = $this->email->value();
if ($primary !== null && trim((string)$primary) !== '') {
return (string)$primary;
}
return null;
}
/**
* Get the explicit invoice email override, if any. Unlike
* {@see getInvoiceEmail()} this does not fall back to the primary email.
*/
public function getInvoiceEmailOverride(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice === null) {
return null;
}
$trimmed = trim((string)$invoice);
return $trimmed === '' ? null : $trimmed;
}
/**
* Set the optional invoice email for the user. Pass null/empty to clear.
* @throws Exception If the email address is invalid
*/
public function setInvoiceEmail(string|null $email): void
{
self::requireSelected();
if ($email === null || trim($email) === '') {
$this->invoice_email->set(null);
return;
}
$trimmed = trim($email);
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$this->invoice_email->set($trimmed);
}
public function isCustomerBarred(int $customer_number): bool
{
if ($customer_number === 0) {
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
* The schema health check verifies that all required DB columns exist
* for the routes the code references. If a column is missing (e.g. a
* migration wasn't run on production), the endpoint returns 503 with
* a clear list of missing columns — much more useful than a generic
* 500 with "Unknown column" hidden in the stack trace.
*/
class adminRoute
{
use route_t;
public function run(): void
{
// Schema health check — used by deploy pipelines, monitoring,
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} catch (\Throwable $e) {
// Bootstrap may fail in environments where $db is
// not yet wired up; report and continue with check
}
}
$report = $this->runSchemaCheck();
$response->setStatus($report['ok'] ? 200 : 503);
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
});
}
/**
* Returns ['ok' => bool, 'missing' => array, ...].
* If ok=false, the deploy should be blocked.
*/
private function runSchemaCheck(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
$requirements = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
foreach ($requirements as $table => $columns) {
$report['tables_checked']++;
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
}
@@ -88,6 +88,18 @@ class productsRoute
return $parsed;
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
@@ -548,5 +560,55 @@ class productsRoute
'edit_product' => 'Edit a product'
]
);
// POST /products/:id/merge — merge a product into another.
// Body: { target_id: int, reason?: string }
// The source product is preserved (so historical order_items references remain valid),
// but is marked as merged in the products table. Reads and new orders should follow
// merged_into_product_id to the target. An audit row is written to product_merges.
$this->post('/products/{id}/merge', function () {
global $response;
$this->requirePermission('edit_product');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('products', 'global', 1, 0, 'MERGE_PRODUCT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$sourceId = $this->routePositiveInt('id');
$targetId = (int)($response->getRequestParameter('target_id') ?? 0);
if ($targetId <= 0) {
$response->error('target_id is required and must be a positive integer', 400);
}
$reason = $response->getRequestParameter('reason');
if ($reason !== null && !is_string($reason)) {
$response->error('reason must be a string', 400);
}
$source = (new products_o())->select($sourceId);
if (!$source->exists()) {
$response->error('Source product not found', 404);
}
try {
$source->mergeInto($targetId, (int)$user->id, $reason);
} catch (\RuntimeException $e) {
(new logs_o())->add('products', 'global', 3, (int)$user->id, 'MERGE_PRODUCT_FAILED', "source={$sourceId} target={$targetId} error=" . $e->getMessage());
$response->error($e->getMessage(), 400);
}
(new logs_o())->add('products', 'global', 1, (int)$user->id, 'MERGE_PRODUCT', "source={$sourceId} target={$targetId}");
$response->success([
'message' => 'Product merged successfully',
'source_product_id' => $sourceId,
'target_product_id' => $targetId,
'merged_into_product_id' => (int)$source->merged_into_product_id->value(),
]);
},
[
'edit_product' => 'Merge a product into another (preserves historical order references; new orders resolve to the target).'
]
);
}
}
@@ -62,9 +62,21 @@ class userInvoicesRoute
self::requireSameLength($id, self::getParameter('id'));
$is_superuser = $this->hasPermission('superuser');
if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {
$response->error('Missing required parameters: po_number, closed_at', 400);
// At least one of po_number or closed_at must be provided. The
// previous message said "Missing required parameters:
// po_number, closed_at" which read as if BOTH were required
// and confused customers trying to invoice (TRU-128).
$response->error('At least one of po_number or closed_at must be provided', 400);
}
if (self::isParametersSet(['closed_at']) && !$is_superuser) {
// Only superusers may set a non-empty closed_at. Customers are
// still allowed to pass an empty/null closed_at to CLEAR a
// previously set value (the field is then set to null below).
$closed_at_is_non_empty = false;
if (self::isParametersSet(['closed_at'])) {
$raw_closed_at = self::getParameter('closed_at');
$closed_at_is_non_empty = ($raw_closed_at !== null && $raw_closed_at !== '');
}
if ($closed_at_is_non_empty && !$is_superuser) {
$response->error('Forbidden: only superusers can update closed_at', 403);
}
// Make sure optional fields are valid
+28 -1
View File
@@ -119,8 +119,19 @@ class usersRoute
if ($role !== 0) {
$this->requirePermission('edit_user_role');
}
// TRU-77 / DRIFT 16: optional dedicated invoice email
$invoice_email = null;
if (isset($data['invoice_email']) && $data['invoice_email'] !== null && $data['invoice_email'] !== '') {
$candidate = trim((string)$data['invoice_email']);
if ($candidate !== '') {
if (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
}
$invoice_email = $candidate;
}
}
// Add the user
(new users_o())->add($data['customer_number'], $data['password'], $role);
(new users_o())->add($data['customer_number'], $data['password'], $role, $invoice_email);
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
// Return a success message
@@ -193,6 +204,22 @@ class usersRoute
}
// Edit the user
(new users_o())->edit((int)$data['id'], (string)$data['customer_number'], $data['role'], $data['password'], $data['display_name']);
// TRU-77 / DRIFT 16: allow updating the dedicated invoice email
if (array_key_exists('invoice_email', $data)) {
$raw = $data['invoice_email'];
if ($raw === null || $raw === '' || $raw === 'null') {
$targetUser->setInvoiceEmail(null);
} else {
$candidate = trim((string)$raw);
if ($candidate === '') {
$targetUser->setInvoiceEmail(null);
} elseif (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
} else {
$targetUser->setInvoiceEmail($candidate);
}
}
}
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']);
// Return a success message
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use objects\products_o;
usesApiSuite();
/**
* Tests for TRU-94: product merging infrastructure.
*
* Verifies that:
* - Merging product A into B preserves historical order_items references (FK still points at A)
* - The source product's merged_into_product_id is set, and resolveActiveProductId() follows it
* - An audit row is written to product_merges
* - Price changes to the target (B) are what new orders will see (since reads resolve to the target)
* - The API endpoint POST /products/{id}/merge behaves as expected and validates inputs
* - The schema is additive and idempotent (running the bootstrap twice is safe)
*/
it('adds merged_into_product_id to products and product_merges table is idempotent', function (): void {
api_test_covers('schema', 'product-merges');
// Calling ensureTables on a clean test DB should be a no-op (column/table already exist
// from earlier schema runs in the same suite, or it should add them without error).
\classes\products_schema_bootstrap::ensureTables();
\classes\products_schema_bootstrap::ensureTables();
$db = api_test_runtime()->db();
$col = $db->query("SHOW COLUMNS FROM `products` LIKE 'merged_into_product_id'");
expect($col)->not->toBeFalse();
expect((int)$col->num_rows)->toBe(1);
$tbl = $db->query("SHOW TABLES LIKE 'product_merges'");
expect($tbl)->not->toBeFalse();
expect((int)$tbl->num_rows)->toBe(1);
});
it('resolveActiveProductId follows merged_into_product_id', function (): void {
$source = api_fixtures()->createProduct([
'name' => 'SF Source (Lastbil)',
'price' => 100,
]);
$target = api_fixtures()->createProduct([
'name' => 'SF Target (Lastbil)',
'price' => 150,
]);
$sourceObj = (new products_o())->select((int)$source['id']);
expect($sourceObj->exists())->toBeTrue();
expect($sourceObj->resolveActiveProductId())->toBe((int)$source['id']);
// No chain yet, and target is unchanged
$targetObj = (new products_o())->select((int)$target['id']);
expect($targetObj->resolveActiveProductId())->toBe((int)$target['id']);
// Perform the merge
$sourceObj->mergeInto((int)$target['id'], null, 'TRU-94 test merge');
expect((int)$sourceObj->merged_into_product_id->value())->toBe((int)$target['id']);
expect($sourceObj->resolveActiveProductId())->toBe((int)$target['id']);
// Reload from DB to confirm persistence
$reloaded = (new products_o())->select((int)$source['id']);
expect($reloaded->resolveActiveProductId())->toBe((int)$target['id']);
expect((int)$reloaded->merged_into_product_id->value())->toBe((int)$target['id']);
});
it('mergeInto preserves historical order_items references and writes an audit row', function (): void {
$source = api_fixtures()->createProduct([
'name' => 'Legacy SF',
'price' => 200,
]);
$target = api_fixtures()->createProduct([
'name' => 'New SF',
'price' => 250,
]);
// Create a historical order and order_item that points at the source.
$user = api_fixtures()->createUser(['name' => 'Merge Test User']);
$cashier = api_fixtures()->createUser(['name' => 'Merge Test Cashier']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => (int)$user['id'],
'department_id' => (int)$department['id'],
]);
$item = api_fixtures()->createOrderItem([
'order_id' => (int)$order['id'],
'product_id' => (int)$source['id'],
'cashier_id' => (int)$cashier['id'],
'price' => 200,
'quantity' => 1,
]);
expect((int)$item['product_id'])->toBe((int)$source['id']);
// Merge source into target
(new products_o())->select((int)$source['id'])->mergeInto((int)$target['id'], null, 'TRU-94 historical preservation');
// Historical order_items.product_id MUST still point at the source.
// (This is the whole point of the merge: we don't rewrite history.)
$db = api_test_runtime()->db();
$row = $db->query("SELECT product_id FROM order_items WHERE id = " . (int)$item['id'])->fetch_array();
expect((int)$row['product_id'])->toBe((int)$source['id']);
// Audit row exists
$audit = $db->query("SELECT * FROM product_merges WHERE source_product_id = " . (int)$source['id'])->fetch_array();
expect($audit)->not->toBeNull();
expect((int)$audit['source_product_id'])->toBe((int)$source['id']);
expect((int)$audit['target_product_id'])->toBe((int)$target['id']);
expect($audit['reason'])->toBe('TRU-94 historical preservation');
});
it('price change on the target is what new orders see (resolution goes to target)', function (): void {
$source = api_fixtures()->createProduct(['name' => 'SF Pre-Merge', 'price' => 100]);
$target = api_fixtures()->createProduct(['name' => 'SF Post-Merge', 'price' => 100]);
(new products_o())->select((int)$source['id'])->mergeInto((int)$target['id']);
// Simulate a price change on the target (the only product new orders can be placed against)
$targetObj = (new products_o())->select((int)$target['id']);
$targetObj->price->set(175);
// The source still resolves to the target, and a fresh read of the target shows the new price
$resolvedId = (new products_o())->select((int)$source['id'])->resolveActiveProductId();
expect($resolvedId)->toBe((int)$target['id']);
$reloaded = (new products_o())->select($resolvedId);
expect((int)$reloaded->price->value())->toBe(175);
});
it('POST /products/{id}/merge requires edit_product permission', function (): void {
$source = api_fixtures()->createProduct(['name' => 'Perm Source']);
$target = api_fixtures()->createProduct(['name' => 'Perm Target']);
// IMPORTANT: do NOT pass ['group_id' => 1] here. group_id 1 is a
// hardcoded superuser/admin in objects\users_o::hasPermission() and
// bypasses the groups_permissions check entirely, so the route would
// 200 instead of 403. createUserSession([], []) creates a fresh empty
// group (id > 1) with no permissions, which is what this test needs.
$session = api_fixtures()->createUserSession([], []);
$response = api_client()->post(
'/products/' . (int)$source['id'] . '/merge',
['target_id' => (int)$target['id']],
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false);
});
it('POST /products/{id}/merge succeeds with edit_product permission', function (): void {
$source = api_fixtures()->createProduct(['name' => 'API Merge Source']);
$target = api_fixtures()->createProduct(['name' => 'API Merge Target']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
$response = api_client()->post(
'/products/' . (int)$source['id'] . '/merge',
['target_id' => (int)$target['id'], 'reason' => 'SENERE 2 — TRU-94'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('source_product_id', (int)$source['id'])
->toHaveKey('target_product_id', (int)$target['id'])
->toHaveKey('merged_into_product_id', (int)$target['id']);
});
it('POST /products/{id}/merge rejects self-merge', function (): void {
$product = api_fixtures()->createProduct(['name' => 'Self Merge']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
$response = api_client()->post(
'/products/' . (int)$product['id'] . '/merge',
['target_id' => (int)$product['id']],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
it('POST /products/{id}/merge rejects double-merge', function (): void {
$a = api_fixtures()->createProduct(['name' => 'A']);
$b = api_fixtures()->createProduct(['name' => 'B']);
$c = api_fixtures()->createProduct(['name' => 'C']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
// First merge succeeds
$first = api_client()->post(
'/products/' . (int)$a['id'] . '/merge',
['target_id' => (int)$b['id']],
$session['headers']
);
$first->assertStatus(200)->assertEnvelope()->assertSuccess();
// Second merge of A into C should fail because A is already merged
$second = api_client()->post(
'/products/' . (int)$a['id'] . '/merge',
['target_id' => (int)$c['id']],
$session['headers']
);
$second
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`sms_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`email_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`wash_certificate_email` VARCHAR(255) NULL,
`invoice_email` VARCHAR(255) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`two_factor_secret` VARCHAR(255) NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
@@ -175,6 +175,11 @@ final class ApiTestRuntime
);
$this->db->set_charset('utf8mb4');
// Expose $GLOBALS['db'] as a classes\db wrapper around the same connection
// so that legacy object code (e.g. products_o::exists / products_o::mergeInto)
// that relies on `global $db` works inside the API test runtime.
$this->bindGlobalLegacyDb($this->db, $dbConfig);
$redisConfig = $this->readRedisConfig();
if ($redisConfig !== null) {
$parameters = [
@@ -204,6 +209,42 @@ final class ApiTestRuntime
$this->bootstrapped = true;
}
/**
* Bind $GLOBALS['db'] to a classes\db wrapper around the active mysqli connection.
*
* The API test runtime speaks to the database through a raw mysqli handle
* (see db() above). However, a lot of the production object layer
* (e.g. objects\products_o::exists, objects\products_o::mergeInto,
* traits\db_object_t) uses `global $db;` and then calls methods on it.
*
* This wrapper re-uses the same underlying mysqli connection so that
* fixtures written via $this->db are visible to the legacy object layer
* and vice versa, without opening a second connection.
*/
private function bindGlobalLegacyDb(mysqli $connection, array $dbConfig): void
{
if (!class_exists(\classes\db::class)) {
// Legacy wrapper not available; tests that don't need it will still pass.
return;
}
if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof \classes\db) {
$legacyDb = new \classes\db([
'host' => (string)$dbConfig['host'],
'user' => (string)$dbConfig['user'],
'password' => (string)$dbConfig['password'],
'database' => (string)$dbConfig['database'],
'port' => (int)$dbConfig['port'],
'ssl_mode' => (string)($dbConfig['ssl_mode'] ?? 'DISABLED'),
]);
$GLOBALS['db'] = $legacyDb;
}
// Share the runtime mysqli handle so reads/writes stay consistent
// with the rest of the API test runtime.
$GLOBALS['db']->conn = $connection;
}
private function bootstrapSchemaIfRequested(): void
{
if ($this->schemaBootstrapped) {
@@ -0,0 +1,243 @@
<?php
use classes\customer_invoice_email_schema_bootstrap;
use classes\customer_mass_import_service;
if (!class_exists('CustomerInvoiceEmailSchemaResultStub')) {
final class CustomerInvoiceEmailSchemaResultStub
{
public int $num_rows = 0;
/** @var list<array<string, mixed>> */
private array $rows;
/** @param list<array<string, mixed>> $rows */
public function __construct(array $rows = [])
{
$this->rows = array_values($rows);
$this->num_rows = count($this->rows);
}
/** @return array<string, mixed>|null */
public function fetch_assoc(): ?array
{
return array_shift($this->rows) ?? null;
}
}
}
if (!class_exists('CustomerInvoiceEmailSchemaDbStub')) {
final class CustomerInvoiceEmailSchemaDbStub
{
public bool $hasUsersTable = true;
public bool $hasInvoiceEmailColumn = false;
/** @var list<string> */
public array $queries = [];
public function query(string $sql): CustomerInvoiceEmailSchemaResultStub
{
$this->queries[] = $sql;
if (str_contains($sql, "SHOW TABLES LIKE 'users'")) {
return $this->hasUsersTable
? new CustomerInvoiceEmailSchemaResultStub([['Tables_in_db' => 'users']])
: new CustomerInvoiceEmailSchemaResultStub();
}
if (str_contains($sql, "SHOW COLUMNS FROM `users` LIKE 'invoice_email'")) {
return $this->hasInvoiceEmailColumn
? new CustomerInvoiceEmailSchemaResultStub([['Field' => 'invoice_email']])
: new CustomerInvoiceEmailSchemaResultStub();
}
return new CustomerInvoiceEmailSchemaResultStub();
}
}
}
it('adds the invoice_email column to the users table when the column is missing', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
expect($db->queries)->toContain('ALTER TABLE `users`
ADD COLUMN invoice_email VARCHAR(255) NULL
AFTER wash_certificate_email');
});
it('does not re-add the invoice_email column when it already exists', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = true;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
it('skips column add when the users table does not exist yet', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = false;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
// --- customer mass import service invoice_email routing (TRU-77) ---
if (!class_exists('CustomerInvoiceEmailMassImportProbe')) {
final class CustomerInvoiceEmailMassImportProbe extends customer_mass_import_service
{
public array $economicSearchResults = [];
public array $createCalls = [];
public array $bootstrapCalls = [];
public ?object $bootstrapUser = null;
public ?object $createResponse = null;
public string $companyName = 'Probe Company';
protected function searchEconomicCustomersByCvr(string $cvr): array
{
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
// The production service no longer mutates $normalized['email'];
// the create call uses the dedicated invoice_email (or the
// primary as a fallback) that import() resolves for it. Mirror
// that here so the recorded payload reflects what is sent to
// e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$this->bootstrapCalls[] = $customerNumber;
if ($this->bootstrapUser === null) {
throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
return $this->bootstrapUser;
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return $this->companyName;
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
// No-op for the routing assertions; tests focus on payload + create call.
}
// Override the DB lookup so the unit test does not need a real
// (or stubbed) mysqli connection. The TRU-77 routing tests treat
// the import as a "new customer" flow, so we hard-code the
// "does not exist locally" answer.
protected function localCustomerNumberExists(int $customerNumber): bool
{
return false;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
return null;
}
}
}
it('routes the e-conomic customer email to the dedicated invoice_email when provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5001;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'faktura@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('faktura@example.com');
expect($result['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBe('faktura@example.com');
});
it('falls back to the primary email when no dedicated invoice_email is provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5002;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBeNull();
});
it('rejects an invalid dedicated invoice_email before contacting e-conomic', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5003;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'not-an-email',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'Invalid invoice email address.');
expect($service->createCalls)->toBe([]);
});
@@ -49,9 +49,16 @@ if (!class_exists('CustomerMassImportServiceProbe')) {
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized): object
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
$this->createCalls[] = $normalized;
// TRU-77 / DRIFT 16: the production service no longer mutates
// $normalized['email'] before calling createEconomicCustomer —
// the dedicated invoice_email (or the primary as a fallback) is
// resolved by import() and passed in as $createEmail. Mirror that
// here so the recorded payload reflects what is sent to e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
@@ -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.'],
]);
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.'],
]);
});
@@ -9,8 +9,12 @@ it('requires id and at least one mutable field for PUT /collected-invoices in us
expect($content)->toContain("self::requireParameters(['id']);");
expect($content)->toContain("\$is_superuser = \$this->hasPermission('superuser');");
expect($content)->toContain("if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {");
expect($content)->toContain("\$response->error('Missing required parameters: po_number, closed_at', 400);");
expect($content)->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {");
// TRU-128: The previous error message ("Missing required parameters:
// po_number, closed_at") read as if BOTH were required and confused
// customers trying to invoice. We now state the actual contract: at
// least one must be provided.
expect($content)->toContain("\$response->error('At least one of po_number or closed_at must be provided', 400);");
expect($content)->toContain("if (\$closed_at_is_non_empty && !\$is_superuser) {");
expect($content)->toContain("\$response->error('Forbidden: only superusers can update closed_at', 403);");
expect($content)->toContain("if ((int)\$invoice->customer_number->value() !== (int)\$user->customer_number->value() && !\$is_superuser) {");
});
@@ -28,3 +32,24 @@ it('supports independent po_number and closed_at updates for PUT /collected-invo
expect($content)->toContain("self::requireDateFormat((string)\$closed_at, self::FORMAT_DATE());");
expect($content)->toContain("\$invoice->closed_at->set(\$closed_at === null || \$closed_at === '' ? null : date('Y-m-d 23:59:59', strtotime((string)\$closed_at . ' 00:00:01')));");
});
it('locks in the TRU-128 bug fix: customers can clear closed_at with null/empty string', function (): void {
// TRU-128 / "Jeg kan ikke fakturere": a non-superuser could not pass
// closed_at at all (even null/empty) because isParametersSet() returns
// true for any present key. The route returned 403 Forbidden and the
// customer could not clear a previously-set closed_at either. The fix
// narrows the forbidden check to *non-empty* closed_at values, matching
// the existing clear-on-null/empty logic further down in the handler.
$routeFile = app_path('routes/userInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
// The "present + non-empty" check must precede the 403 guard, so
// clearing closed_at (passing null or "") for a non-superuser is allowed.
expect($content)->toMatch(
'/\$closed_at_is_non_empty\s*=\s*false;\s*if\s*\(self::isParametersSet\(\[\'closed_at\'\]\)\)\s*\{[^}]*\$closed_at_is_non_empty\s*=\s*\(\$raw_closed_at\s*!==\s*null\s*&&\s*\$raw_closed_at\s*!==\s*\'\'\);[^}]*\}\s*if\s*\(\$closed_at_is_non_empty\s*&&\s*!\$is_superuser\)\s*\{[^}]*Forbidden:\s*only\s*superusers/s'
);
// The previous shape of the guard (which would always fire for any
// present closed_at, including null) must no longer be present.
expect($content)->not->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {");
});
@@ -0,0 +1,206 @@
<?php
/**
* Contract test: every column that the code expects to find in the
* `users` table must exist. Catches the production failure mode
* where a migration was added to code but never run on the database
* (e.g. "Unknown column 'invoice_email' in 'SELECT'" — TRU-77).
*
* This test runs against the test database (configured in
* phpunit.xml / Pest configuration). It does NOT run against
* production — that's covered by the `/admin/schema-check` HTTP
* endpoint in `adminRoute.php` which the deploy pipeline hits.
*/
app_require('classes/customer_invoice_email_schema_bootstrap.php');
use classes\customer_invoice_email_schema_bootstrap;
const REQUIRED_USERS_COLUMNS = [
// TRU-77 (added 2026-08-16) — the column that was missing in
// production after the migration was merged to master.
'invoice_email',
// Older required columns that the code references.
'wash_certificate_email',
'email',
'customer_number',
'phone_country_code',
'phone',
'group_id',
'created_at',
];
/**
* The unit test bootstrap does not create a $db global. This contract
* test is unique in that it needs a real database to verify schema
* state, so wire one up here using the same CONFIG_DB_* env vars the
* rest of the CI suite exports. If the database is unavailable, the
* tests below will fail with a clear "no_db_connection" error.
*/
schema_health_check_test_wire_db();
function schema_health_check_test_wire_db(): void
{
if (isset($GLOBALS['db']) && is_object($GLOBALS['db'])) {
return;
}
if (!class_exists('mysqli')) {
return;
}
$host = (string)(getenv('CONFIG_DB_HOST') ?: 'mysql-debug');
$user = (string)(getenv('CONFIG_DB_USER') ?: 'root');
$password = (string)(getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password');
$database = (string)(getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug');
$port = (int)(getenv('CONFIG_DB_PORT') ?: 3306);
try {
mysqli_report(MYSQLI_REPORT_OFF);
$conn = new mysqli($host, $user, $password, $database, $port);
if ($conn->connect_errno) {
return;
}
$conn->set_charset('utf8mb4');
} catch (\Throwable $e) {
return;
}
$GLOBALS['db'] = new class($conn) {
private mysqli $conn;
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function query(string $sql)
{
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result ? $result->fetch_assoc() : null;
}
public function close(): void
{
try {
$this->conn->close();
} catch (\Throwable) {
}
}
};
}
/**
* The unit suite starts with a freshly-dropped `nnks_db_debug` database
* (see run_ci_suite::reset_ci_state). The schema bootstrap only owns
* the `users` table; `invoices` and `bookings` are managed by other
* migrations that don't run in the unit suite. Create the bare-minimum
* schema that adminRoute::runSchemaCheck needs so the third test can
* verify the "all columns exist" happy path.
*/
function schema_health_check_test_ensure_aux_tables(): void
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$create = function (string $table, string $createSql, array $requiredColumns) use ($db): void {
$r = $db->query("SHOW TABLES LIKE '{$table}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query($createSql);
return;
}
foreach ($requiredColumns as $column => $definition) {
$r = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
};
$create('invoices', "CREATE TABLE `invoices` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
po_number VARCHAR(64) NULL,
closed_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'po_number' => 'VARCHAR(64) NULL',
'closed_at' => 'DATETIME NULL',
'customer_number' => 'INT NOT NULL DEFAULT 0',
]);
$create('bookings', "CREATE TABLE `bookings` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
department INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'customer_number' => 'INT NOT NULL DEFAULT 0',
'department' => 'INT NULL',
]);
}
beforeEach(function () {
// Self-heal: run the schema bootstrap so the test DB has all
// the columns the contract requires. The bootstrap is additive
// and idempotent — safe to run on every test.
if (!isset($GLOBALS['db']) || !is_object($GLOBALS['db'])) {
schema_health_check_test_wire_db();
}
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
schema_health_check_test_ensure_aux_tables();
});
it('users table has every required column the code references', function () {
global $db;
expect($db)->toBeObject();
expect(method_exists($db, 'query'))->toBeTrue();
$missing = [];
foreach (REQUIRED_USERS_COLUMNS as $column) {
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `users` LIKE '{$safeColumn}'");
if (!$result || (int)$result->num_rows === 0) {
$missing[] = $column;
}
}
expect($missing)->toBe(
[],
"users table is missing required columns: " . implode(', ', $missing)
. ". Did the migration run? See customer_invoice_email_schema_bootstrap."
);
});
it('invoice_email column accepts a normal email address', function () {
global $db;
// Insert a throwaway user with an invoice_email, read it back.
// If the column doesn't exist or the type is wrong, this fails.
$email = 'test-invoice-' . uniqid() . '@example.com';
$customerNumber = 99900000 + random_int(1, 99999);
$db->query("INSERT INTO `users` (customer_number, display_name, email, invoice_email) VALUES ({$customerNumber}, 'Contract Test', '{$email}', 'invoice@example.com')");
$result = $db->query("SELECT invoice_email FROM `users` WHERE customer_number = {$customerNumber}");
expect($result)->toBeObject();
$row = $result->fetch_assoc();
expect($row['invoice_email'] ?? null)->toBe('invoice@example.com');
// Cleanup
$db->query("DELETE FROM `users` WHERE customer_number = {$customerNumber}");
});
it('admin schema-check endpoint reports ok=true when all columns exist', function () {
$admin = new \routes\adminRoute();
$reflection = new ReflectionClass($admin);
$method = $reflection->getMethod('runSchemaCheck');
$method->setAccessible(true);
$report = $method->invoke($admin);
expect($report['ok'])->toBeTrue(
'schema check failed: ' . json_encode($report['missing'] ?? [])
);
expect($report['columns_checked'])->toBeGreaterThan(0);
});
@@ -0,0 +1,97 @@
<?php
/**
* Program-registry contract tests for TRU-19.
*
* Locks the architecture decision that the api does NOT expose a /programs
* endpoint that returns user-facing program names ("FF Uvs", "10min", "SF",
* etc.). Those names live on the wash bay hardware itself, not in the api.
*
* The api exposes MACHINE TYPES (e.g. "Mafa 5", "Washtec") via
* /department/selfserve/machine-types and PROGRAM PICKER relay control
* via /modules/self-serve/lane/relay/machine_program_picker/{status,set,enable,...}.
*
* The dashboard (pleno-vue) renders a numeric button registry (0-11) that
* maps to the physical programs on the wash bay. If a /programs endpoint
* ever appears in the api by accident, this test will fail and force the
* author to either (a) document the new endpoint and update this test, or
* (b) remove the spurious endpoint.
*
* Also locks the /department/selfserve/machine-types endpoint as a
* reachable, list-returning smoke target — this is the closest thing to
* a /programs endpoint that the api offers, and it should remain stable.
*/
it('does not expose a /programs endpoint (program names live on the wash bay)', function (): void {
$routesDir = app_path('routes');
$moduleRoutesDirs = glob(app_path('modules') . '/*/routes') ?: [];
$routeFiles = array_merge(
glob($routesDir . '/*.php') ?: [],
// Collect per-module route files
array_merge(...array_map(static fn($dir) => glob($dir . '/*.php') ?: [], $moduleRoutesDirs))
);
expect($routeFiles)->not->toBeEmpty('Expected to find at least one route file');
foreach ($routeFiles as $file) {
$source = file_get_contents($file);
expect($source)->not->toBeFalse("Failed to read route file: {$file}");
// Check for any route that would expose a /programs-style endpoint.
// The regex matches a $this->get(...) or $this->post(...) call with a
// /programs URI segment. We use word boundaries to avoid false
// positives on /modules/self-serve/lane/relay/machine_program_picker/*.
$matches = preg_match_all(
'/\$this->(?:get|post|put|delete|patch)\s*\(\s*[\'"]\/[^\'"]*\/programs[\'"]/',
$source,
$ignored
);
expect($matches)->toBe(
0,
"Found a /programs endpoint in {$file}. Program names live on the wash bay hardware — "
. 'the api should not expose them. If you intentionally want to add one, update this test '
. 'and document the new endpoint in docs/.'
);
}
});
it('exposes /department/selfserve/machine-types as the api-side program-adjacent endpoint', function (): void {
$machineTypesRoute = file_get_contents(app_path('routes/departmentSelfserveMachineTypesRoute.php'));
expect($machineTypesRoute)->not->toBeFalse();
expect($machineTypesRoute)->toContain('/department/selfserve/machine-types');
expect($machineTypesRoute)->toContain("'list_department_selfserve_machine_types'");
// The route must call $response->success(...) which is the standard
// "200 OK with JSON body" envelope. The contract is: a GET to this
// endpoint returns a JSON list of machine types.
expect($machineTypesRoute)->toContain('$response->success(');
// The route must enforce the list_* permission so unauthorized callers
// cannot enumerate machine types.
expect($machineTypesRoute)->toContain("requirePermission('list_department_selfserve_machine_types')");
});
it('exposes /modules/self-serve/lane/relay/machine_program_picker/* for program picker relay control', function (): void {
$selfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($selfServeRoute)->not->toBeFalse();
// The program picker relay endpoints must exist. Pest's toContain does
// not accept a custom failure message, so we collect failures into a
// single assert at the end with a list of missing endpoints.
$expectedEndpoints = [
'/modules/self-serve/lane/relay/machine_program_picker/status',
'/modules/self-serve/lane/relay/machine_program_picker/set',
'/modules/self-serve/lane/relay/machine_program_picker/enable',
];
$missing = array_values(array_filter(
$expectedEndpoints,
static fn(string $endpoint): bool => !str_contains($selfServeRoute, $endpoint)
));
expect($missing)->toBe(
[],
'Missing program picker relay endpoints: ' . implode(', ', $missing)
);
});
@@ -0,0 +1,59 @@
<?php
/*
* Regression test for TRU-18 / AUT-14:
* "api — truckwash.io invoices route to wrong EC account; some users"
*
* Root cause: getUserByCustomerNumber() in objects/users_o.php trusted the
* inverse Redis cache (customer_number -> user_id) without verifying that the
* user it loaded actually owns the requested EC customer_number in the local
* DB. When that cache went stale (e.g. after a customer_number re-mapping on
* a code path that did not clear the inverse cache), getUserByCustomerNumber()
* would return the wrong user. Downstream invoice code (getCustomerEcocomicData,
* setCustomerNumber) would then use that wrong user's current customer_number
* and route the draft invoice to the wrong Economic account.
*
* The fix verifies the loaded user owns the requested customer_number after
* the Redis fast-path, clears the stale cache entry, and re-fetches when the
* fast-path returned a user whose actual customer_number does not match.
*/
it('revalidates loaded user against requested customer_number after Redis fast-path (TRU-18)', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
// The fast-path (Redis cache hit) must verify the loaded user actually
// owns the requested EC customer_number before returning.
expect($content)->toContain('// BUG FIX (TRU-18 / AUT-14)');
expect($content)->toContain('getUserByCustomerNumber(int $customer_number)');
expect($content)->toContain('self::redisCache()?->get_user_id_from_customer_number($customer_number)');
expect($content)->toContain('$this->getObjectProperties();');
expect($content)->toContain('if ((int)$this->customer_number->value() !== $customer_number) {');
expect($content)->toContain('self::redisCache()?->clear_user_id_from_customer_number($customer_number);');
expect($content)->toContain('return $this->getUserByCustomerNumber($customer_number);');
});
it('keeps the DB lookup path as the source of truth when the Redis cache is empty or stale', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
// After clearing the stale cache, the recursive call must fall through to
// the DB query path which selects by exact customer_number match.
expect($content)->toContain('SELECT id FROM $this->table WHERE customer_number = \'$customer_number\'');
});
it('does not use the requested customer_number for any unrelated lookup in the invoice export flow', function (): void {
// Sanity check: the invoice export flow must go through getCustomerByOrderId
// -> getUserByCustomerNumber, so the TRU-18 fix above is the choke point.
$ordersFile = app_path('objects/orders_o.php');
$content = file_get_contents($ordersFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('public function getCustomerByOrderId(?string $order_id): users_o');
expect($content)->toContain("SELECT customer_id FROM orders WHERE id = \$order_id");
expect($content)->toContain('return (new users_o())->getUserByCustomerNumber($row[\'customer_id\']);');
});