Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c08dea56 | ||
|
|
9e18f8988b | ||
|
|
34df80530c | ||
|
|
3d0a8eeae7 | ||
|
|
60222a7d91 | ||
|
|
78b11d0b79 | ||
|
|
55ddabb0ee | ||
|
|
16048e2ce3 |
@@ -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."
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'],
|
||||
]);
|
||||
});
|
||||
|
||||
+27
-2
@@ -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\']);');
|
||||
});
|
||||
Reference in New Issue
Block a user