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."
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ class ordersRoute
|
||||
$order->setPendingHandheldIndicator();
|
||||
}
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . (int)$order->id . ')');
|
||||
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . $data['department_id'] . ')');
|
||||
// Return a success message, containing the orders array
|
||||
$response->success($order->asArray());
|
||||
} else {
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
+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) {");
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('logs the newly created order id (not the department id) when POST /orders succeeds', function (): void {
|
||||
$routeFile = app_path('routes/ordersRoute.php');
|
||||
$content = file_get_contents($routeFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
|
||||
// The POST /orders handler must log the id of the order that was just
|
||||
// persisted by addArray(), not the department id from the request body.
|
||||
// Without this, the audit trail for the "check-in creates 0-orders" bug
|
||||
// is useless — every successful create logs the wrong identifier.
|
||||
expect($content)->toContain("'Successfully added an order (ID: ' . (int)\$order->id . ')'");
|
||||
|
||||
// Guard against the previous copy/paste regression reappearing.
|
||||
expect($content)->not->toContain("'Successfully added an order (ID: ' . \$data['department_id'] . ')'");
|
||||
});
|
||||
@@ -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