Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10814e68ae |
@@ -36,13 +36,6 @@ retain squash merging, disable merge commits and rebase merging, enable
|
||||
auto-merge and branch-update suggestions, delete merged branches automatically,
|
||||
keep the Actions token read-only, and prevent Actions from approving reviews.
|
||||
|
||||
## Activation record
|
||||
|
||||
Repository ruleset `19041620` was activated on 2026-07-16 after preparation
|
||||
PR #311 passed `Required CI` and the merged `master` commit passed both
|
||||
`Required CI` and the `Release Manager gate`. This documentation update is
|
||||
the after-activation canary for the normal protected pull-request path.
|
||||
|
||||
## Break glass
|
||||
|
||||
When an incident cannot wait for the normal gate:
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -20,55 +9,46 @@ on:
|
||||
- canary
|
||||
- internal
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
pull-requests: read
|
||||
checks: read
|
||||
steps:
|
||||
- name: Require Qodana Cloud token
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
persist-credentials: false
|
||||
- name: Mark repository as safe for Git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Prepare Qodana cache directories
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
||||
- name: Detect Qodana Cloud token
|
||||
id: qodana-token
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
if [ -n "${QODANA_TOKEN:-}" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out the analyzed commit
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: 'Qodana Scan'
|
||||
if: ${{ steps.qodana-token.outputs.present == 'true' }}
|
||||
uses: JetBrains/qodana-action@v2026.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Qodana
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891 # v2026.1.3
|
||||
with:
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
use-annotations: true
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
upload-result: false
|
||||
pr-mode: false
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
QODANA_ENDPOINT: 'https://qodana.cloud'
|
||||
|
||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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."
|
||||
+50
-39
@@ -19,33 +19,35 @@ concurrency:
|
||||
jobs:
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
# Docker jobs use disposable workspaces so root-owned container artifacts cannot poison later checkouts.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [unit, integration, api, legacy]
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -59,7 +61,7 @@ jobs:
|
||||
- name: Upload PHP suite logs
|
||||
if: ${{ failure() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: php-${{ matrix.suite }}-logs
|
||||
path: .tmp/ci-logs/${{ matrix.suite }}
|
||||
@@ -68,16 +70,14 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -115,23 +115,25 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
@@ -145,7 +147,7 @@ jobs:
|
||||
docker compose -f docker-compose.example.yml config > /dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -159,9 +161,8 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
COMPOSE_PROFILES: dev
|
||||
@@ -174,17 +175,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
@@ -235,7 +240,7 @@ jobs:
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -376,7 +381,7 @@ jobs:
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
@@ -406,6 +411,12 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
|
||||
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
echo "Release Manager gate failed with HTTP $http_code." >&2
|
||||
exit 1
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
/docker-compose.yml
|
||||
/services/nginx/app/vendor/
|
||||
/services/nginx/app/modules/washcertificates/vendor/
|
||||
/services/nginx/app/.phpunit.cache/
|
||||
/services/nginx/letsencrypt/
|
||||
*.pem
|
||||
*.log.gz
|
||||
|
||||
@@ -24,7 +24,6 @@ RUN set -eux; \
|
||||
libzip-dev \
|
||||
mariadb-client \
|
||||
nginx \
|
||||
openssl \
|
||||
pkg-config \
|
||||
redis-tools \
|
||||
unzip \
|
||||
@@ -47,8 +46,6 @@ RUN set -eux; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY scripts/bird-control-plane-activate.php /var/www/html/scripts/bird-control-plane-activate.php
|
||||
COPY scripts/bird-control-plane-auto-activate.php /var/www/html/scripts/bird-control-plane-auto-activate.php
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
@@ -64,8 +61,6 @@ RUN set -eux; \
|
||||
fi; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
|
||||
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
|
||||
php -r 'exit(function_exists("proc_open") && extension_loaded("openssl") ? 0 : 1);'; \
|
||||
test "$(openssl pkey -pubin -in /var/www/html/modules/bird/resources/control-plane-bootstrap-public.pem -outform DER | sha256sum | cut -d " " -f 1)" = "6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21"; \
|
||||
chown -R www-data:www-data /var/www/html; \
|
||||
chmod -R 755 /var/www/html
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ $MINIO = [
|
||||
'access_key' => '', // Minio access
|
||||
'secret_key' => '' // Minio secret key
|
||||
];
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Set through SLACK_DEFAULT_WEBHOOK; never commit a production webhook URL.
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
$REDIS_CONFIG = [
|
||||
'host' => '', // Redis host (IP address)
|
||||
'user' => '', // Redis user
|
||||
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
- redis
|
||||
- mysql
|
||||
- edge-broker
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
env_file:
|
||||
- .env.example
|
||||
environment:
|
||||
|
||||
@@ -367,7 +367,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -425,7 +425,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -1,115 +0,0 @@
|
||||
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
|
||||
|
||||
## Audit findings
|
||||
|
||||
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
|
||||
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
|
||||
- That separate app runs `php index.php run cron-worker` as a long-running process
|
||||
- Tracks worker heartbeats in a `cron_worker_state` table
|
||||
|
||||
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
|
||||
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
|
||||
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
|
||||
for creating a new application for the cron worker are not stable/reliable in
|
||||
our setup.
|
||||
|
||||
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
|
||||
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
|
||||
is sound. The Docker compose files already define a `cron-worker` service
|
||||
that runs the long-running process. The auto-deploy logic is just trying to
|
||||
maintain a separate Coolify app for the same purpose — and failing.
|
||||
|
||||
## The plan
|
||||
|
||||
### 1. Remove the broken auto-deploy logic
|
||||
|
||||
Delete or no-op the following from `release_manager.php`:
|
||||
- `cronWorkerStatus()`
|
||||
- `deployCronWorker()`
|
||||
- `deployCronWorkerForApiTarget()`
|
||||
- `deployCronWorkerAfterApiDeployment()`
|
||||
- `cronWorkerAutoprovisionEnabled()`
|
||||
- `cronWorkerAutoprovisionRequired()`
|
||||
- `cronWorkerTarget*()` (5 methods)
|
||||
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
|
||||
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
|
||||
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
|
||||
- `cronWorkerDeployContext()`
|
||||
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
|
||||
- `cronWorkerChannels()`, `cronWorkersForTarget()`
|
||||
- `cronWorkerSourceFromCronTarget()`
|
||||
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
|
||||
- The `$result['cron_worker'] = ...` call after API deployment
|
||||
|
||||
Keep:
|
||||
- `cron_worker.php` class (the actual worker)
|
||||
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
|
||||
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
|
||||
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
|
||||
- The `cron-worker` service in `docker-compose*.yml`
|
||||
- The `cron-worker` case in `cli.php`
|
||||
|
||||
### 2. Remove the corresponding tests
|
||||
|
||||
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
|
||||
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
|
||||
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
|
||||
|
||||
### 3. Add a reliable 5-min cron mechanism
|
||||
|
||||
Two-layer approach:
|
||||
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
|
||||
tasks that need to run frequently (60s intervals, etc.). Started automatically
|
||||
with the rest of the stack.
|
||||
2. **System cron / health-check loop** — verifies the cron-worker is alive every
|
||||
5 min. If no fresh heartbeat in 10 min, alert.
|
||||
|
||||
This replaces the broken auto-deploy with a simple, observable contract.
|
||||
|
||||
### 4. Add a verification harness
|
||||
|
||||
`/workspace/scripts/verify-api-cron.py`:
|
||||
- Hits the API's `cronWorkerStatus` endpoint
|
||||
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
|
||||
- If no fresh heartbeat in 10 min, post to #ai-daily
|
||||
- Run every 5 min via a new cron job
|
||||
|
||||
### 5. Update documentation
|
||||
|
||||
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
|
||||
- `openapi.yaml` — remove `cron_worker_status` route documentation
|
||||
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
|
||||
- [ ] No tests reference removed methods
|
||||
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
|
||||
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
|
||||
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
|
||||
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
|
||||
- [ ] PR created, tests pass, merge
|
||||
|
||||
## Risk
|
||||
|
||||
- **Removing `deployCronWorker*` could break live deployments** if someone is
|
||||
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
|
||||
HTTP route returning a friendly "removed" message instead of deleting it.
|
||||
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
|
||||
break dashboards. Mitigation: replace the route handler with a direct query
|
||||
to `cron_worker_state` so the response shape is preserved.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create a feature branch `fix/remove-coolify-cron-worker`
|
||||
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
|
||||
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
|
||||
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
|
||||
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
|
||||
6. Edit `cli.php`: no change needed (cron-worker case still works)
|
||||
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
|
||||
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
|
||||
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
|
||||
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
|
||||
11. Run the test suite locally
|
||||
12. Push branch, create PR, get user review
|
||||
@@ -9856,7 +9856,6 @@
|
||||
"Orders"
|
||||
],
|
||||
"summary": "Create Stripe payment intent",
|
||||
"description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.",
|
||||
"operationId": "createStripePaymentIntent",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -9874,6 +9873,9 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Security documentation
|
||||
|
||||
This folder holds security-related planning, post-mortems, and pen-test
|
||||
artefacts for the Truck Wash ApS platform.
|
||||
|
||||
| Doc | Purpose | Status |
|
||||
| --- | --- | --- |
|
||||
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
|
||||
|
||||
Conventions:
|
||||
|
||||
- Pen-test reports and any raw findings live in date-stamped subfolders
|
||||
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
|
||||
repository — only the planning docs and re-test acceptance letters are.
|
||||
- All security work is tracked under the Linear project
|
||||
*UI Library & Pen Testing*.
|
||||
@@ -1,304 +0,0 @@
|
||||
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
|
||||
|
||||
> ## ⛔ CANCELLED — DO NOT EXECUTE
|
||||
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
|
||||
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
|
||||
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
|
||||
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
|
||||
>
|
||||
> ---
|
||||
|
||||
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
|
||||
**Project:** UI Library & Pen Testing
|
||||
**Priority:** Medium
|
||||
**Status (this doc):** Draft v1 — ready for engineering + management review
|
||||
**Author:** bugfix sub-agent (TRU-80)
|
||||
**Date:** 2026-08-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
|
||||
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
|
||||
is intended to validate the security posture of the customer- and operator-facing
|
||||
production stack before further public rollout and ahead of any major commercial
|
||||
expansion (e.g. additional self-serve sites, additional payment integrations).
|
||||
|
||||
This document is the planning artefact for TRU-80. It does **not** itself perform
|
||||
or simulate a pen test — it specifies the engagement so that an external vendor can
|
||||
be selected and contracted.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope (in)
|
||||
|
||||
The following systems are **in scope** for the engagement. Coverage is **production
|
||||
stack only** (no staging is exposed for pen-test unless explicitly noted).
|
||||
|
||||
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
|
||||
|
||||
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
|
||||
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
|
||||
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
|
||||
- Authentication / session endpoints, including:
|
||||
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
|
||||
`subusersRoute.php`, `limitedBackofficeRoute.php`
|
||||
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
|
||||
- Authorization model: role-based access (customer / sub-user / backoffice /
|
||||
superuser) and per-customer data isolation.
|
||||
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
|
||||
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
|
||||
collected-invoices, order routes.
|
||||
- Payment integration: Stripe module (`moduleStripeRoute.php`).
|
||||
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
|
||||
token handling, invoice push.
|
||||
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
|
||||
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
|
||||
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
|
||||
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
|
||||
Redis trait (`redis_t.php`).
|
||||
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
|
||||
our code consumes it; the upstream WP instance is **out of scope** unless
|
||||
hosted by us.
|
||||
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
|
||||
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
|
||||
`coolify` deploy config. Black-box reachable attack surface only.
|
||||
|
||||
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
|
||||
|
||||
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
|
||||
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
|
||||
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
|
||||
- API client and token storage in the SPA (where tokens live, at-rest
|
||||
protection, refresh flow).
|
||||
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
|
||||
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
|
||||
the root (`openapi.yaml`).
|
||||
- Capacitor deep-link / universal-link / custom-scheme handling
|
||||
(`capacitor.config.ts`).
|
||||
|
||||
### 2.3 Infrastructure & cross-cutting (in)
|
||||
|
||||
- TLS configuration (cert chain, HSTS, cipher suites) on the production
|
||||
public host.
|
||||
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
|
||||
Permissions-Policy, X-Content-Type-Options).
|
||||
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
|
||||
- Email & SMS notification paths only as far as they can be abused for
|
||||
spoofing / phishing of our users (we control the From domain).
|
||||
|
||||
### 2.4 Out of scope (explicitly)
|
||||
|
||||
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
|
||||
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
|
||||
not the third party itself.
|
||||
- Internal office LAN, employee laptops, MDT, and physical site hardware
|
||||
(gate controllers, scanners) — these are covered by a separate physical /
|
||||
OT scope and **out of scope** for this IT pen test.
|
||||
- Denial-of-service / load testing.
|
||||
- Social engineering of Truck Wash staff.
|
||||
- Source-code review of `node_modules` / vendor dependencies (the engagement
|
||||
will use SCA tooling to flag known CVEs, but not audit transitive deps).
|
||||
- Any production data exfiltration — the vendor will be given sanitised or
|
||||
test accounts and synthetic data only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
Industry-standard, manual-led engagement with tooling support. Recommended
|
||||
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
|
||||
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
|
||||
use **OWASP MASVS** as the checklist.
|
||||
|
||||
Phases (estimated total: 12 working days of vendor effort, see §6):
|
||||
|
||||
1. **Scoping & recon (1 day)**
|
||||
- Confirm target list, accounts, and rules of engagement.
|
||||
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
|
||||
- Active recon limited to non-destructive fingerprinting.
|
||||
2. **API pen test (3 days)**
|
||||
- AuthN/AuthZ boundary testing on every route group in §2.1.
|
||||
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
|
||||
wash certificates, sub-users, customer notes).
|
||||
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
|
||||
deserialisation, header injection.
|
||||
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
|
||||
discount stacking, sub-user privilege escalation.
|
||||
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
|
||||
3. **Web SPA pen test (2 days)**
|
||||
- XSS (reflected, stored, DOM-based) including Vue template injection.
|
||||
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
|
||||
- Open-redirect / OAuth misconfig in any SSO flow.
|
||||
- CSP / SRI effectiveness.
|
||||
4. **Mobile (Capacitor) review (2 days)**
|
||||
- Static analysis of the built APK / IPA (Capacitor WebView).
|
||||
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
|
||||
custom-scheme handlers).
|
||||
- Local storage of tokens, biometric bypass if implemented.
|
||||
- Deep-link / universal-link hijack attempts.
|
||||
5. **Infrastructure & config (1.5 days)**
|
||||
- TLS, headers, cookie flags, HSTS preload eligibility.
|
||||
- NGINX hardening review (based on provided config snapshots).
|
||||
- Docker / coolify surface only as externally reachable.
|
||||
6. **SCA / dependency check (0.5 day)**
|
||||
- `composer.json` and `package.json` SCA scan.
|
||||
- High-severity known-CVE report only; no deep audit.
|
||||
7. **Exploitation & PoC (1 day)**
|
||||
- Build proofs-of-concept for any Critical / High findings.
|
||||
8. **Reporting & re-test (1 day)**
|
||||
- Draft report → vendor walkthrough → final report.
|
||||
- Re-test of fixed findings is scoped separately (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 4. Rules of engagement (RoE)
|
||||
|
||||
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
|
||||
exploitation only with prior written approval per critical finding.
|
||||
- **Contact channel:** shared Signal thread + email; vendor given a Slack
|
||||
guest account in a dedicated `#sec-pentest-2026Q4` channel.
|
||||
- **Stop conditions:** any finding that risks data loss, payment integrity,
|
||||
or production gate operation → immediate stop + phone call to on-call.
|
||||
- **Data handling:** vendor may only use synthetic / test data. No
|
||||
exfiltration of real customer PII. All artifacts returned or destroyed at
|
||||
end of engagement (TBD in contract).
|
||||
- **Coverage of third parties:** the vendor will not test Stripe / Economic
|
||||
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
|
||||
we follow responsible-disclosure to the vendor ourselves.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deliverables
|
||||
|
||||
1. **Kick-off doc** (this plan, signed off by both parties).
|
||||
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
|
||||
findings list).
|
||||
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
|
||||
Critical / High so we can start patching in parallel.
|
||||
4. **Final report (PDF + JSON)** including:
|
||||
- Executive summary, risk heatmap, business-impact narrative.
|
||||
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
|
||||
screenshots / Burp session, recommended fix, references.
|
||||
- SCA dependency report as an appendix.
|
||||
5. **Re-test letter** (separate SOW, see §6).
|
||||
6. **Knowledge transfer**: 60-min session for engineering on the top 5
|
||||
findings.
|
||||
|
||||
---
|
||||
|
||||
## 6. Budget & scheduling
|
||||
|
||||
### 6.1 Indicative effort
|
||||
|
||||
| Phase | Days | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1. Scoping & recon | 1.0 | joint with us |
|
||||
| 2. API pen test | 3.0 | |
|
||||
| 3. Web SPA | 2.0 | |
|
||||
| 4. Mobile (Capacitor) | 2.0 | |
|
||||
| 5. Infra & config | 1.5 | |
|
||||
| 6. SCA | 0.5 | tooling-led |
|
||||
| 7. Exploitation / PoC | 1.0 | |
|
||||
| 8. Reporting | 1.0 | incl. 1 review round |
|
||||
| **Total** | **12.0 days** | |
|
||||
|
||||
### 6.2 Indicative cost (DKK, ex. VAT)
|
||||
|
||||
Pricing varies significantly with vendor. Three realistic budget tiers for
|
||||
procurement:
|
||||
|
||||
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 – 16 000 | **144 000 – 192 000** | Best fit for our stack size, Danish-language reporting available. |
|
||||
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 – 22 000 | **180 000 – 264 000** | More brand name, more bureaucracy, stronger report templates. |
|
||||
| Top-tier / Big-4 style | 25 000 – 40 000 | **300 000 – 480 000** | Overkill for current footprint; revisit at Series-A. |
|
||||
|
||||
**Recommended envelope: 180 000 – 220 000 DKK** (mid-tier, 12 days) plus a
|
||||
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
|
||||
report).
|
||||
|
||||
Add ~5 000 DKK contingency for incident-response hours if a Critical is
|
||||
found mid-engagement.
|
||||
|
||||
### 6.3 Schedule (proposed)
|
||||
|
||||
- **2026-08-25** — this plan reviewed and signed off by management.
|
||||
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
|
||||
proposals, evaluate.
|
||||
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
|
||||
- **2026-09-22 (week 39)** — engagement kick-off.
|
||||
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
|
||||
weeks, vendor working in parallel with their normal cadence).
|
||||
- **2026-10-08** — draft report.
|
||||
- **2026-10-15** — final report + walkthrough.
|
||||
- **2026-11-15** — re-test (retainer).
|
||||
|
||||
All dates are **provisional** until a vendor is selected.
|
||||
|
||||
### 6.4 Vendor shortlist (candidates to approach)
|
||||
|
||||
We will request proposals from at least 3 of the following (final shortlist
|
||||
to be confirmed with management):
|
||||
|
||||
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
|
||||
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
|
||||
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
|
||||
for enterprise due-diligence.
|
||||
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
|
||||
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
|
||||
|
||||
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
|
||||
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
|
||||
|
||||
We should land these before the vendor starts — they reduce noise and let
|
||||
the vendor focus on real issues:
|
||||
|
||||
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
|
||||
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
|
||||
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
|
||||
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
|
||||
Gateway webhooks
|
||||
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
|
||||
- [ ] Rate-limit on auth, password reset, and OTP endpoints
|
||||
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
|
||||
- [ ] File-server (`file_server.php`) path-traversal tests in CI
|
||||
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
|
||||
high+ vulns
|
||||
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
|
||||
removed
|
||||
- [ ] Secrets: no production keys in repo (`git log -S` audit)
|
||||
|
||||
This list is also the basis for re-test acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for management
|
||||
|
||||
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
|
||||
2. Confirm legal/procurement owner and contract template.
|
||||
3. Confirm whether to require a Danish-language final report (recommended).
|
||||
4. Confirm re-test budget is approved up-front, or per-finding.
|
||||
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
|
||||
6-day "API + SPA only" first pass.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
|
||||
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
|
||||
- OWASP MASVS — https://mas.owasp.org/MASVS/
|
||||
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
|
||||
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
|
||||
|
||||
---
|
||||
|
||||
*This document is a planning artefact, not the test itself. Once approved, a
|
||||
separate SOW will be drafted with the selected vendor and linked from this
|
||||
issue.*
|
||||
@@ -7,5 +7,4 @@
|
||||
|
||||
<!-- AUTO-GENERATED, DO NOT EDIT -->
|
||||
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
|
||||
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
|
||||
</topic>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</chapter>
|
||||
<chapter title="Operation" id="operation">
|
||||
<p>Operation ID: <code>createStripePaymentIntent</code></p>
|
||||
<p>Creates a Stripe Terminal card payment intent with fixed 25% moms.</p>
|
||||
<p>Create Stripe payment intent</p>
|
||||
</chapter>
|
||||
<chapter title="Authentication" id="authentication">
|
||||
<p>Security requirements:</p>
|
||||
@@ -32,6 +32,9 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# XL Vask Selvvask surface — inventory & simplification plan
|
||||
|
||||
## Scope
|
||||
|
||||
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
|
||||
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
|
||||
operator-facing review and order-creation flow.
|
||||
|
||||
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
|
||||
|
||||
## Files removed
|
||||
|
||||
| Path | Reason |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
|
||||
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
|
||||
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
|
||||
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
|
||||
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
|
||||
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
|
||||
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
|
||||
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
|
||||
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
|
||||
|
||||
## Code changes (kept & simplified)
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
|
||||
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
|
||||
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
|
||||
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
|
||||
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
|
||||
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
|
||||
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
|
||||
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
|
||||
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
|
||||
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
|
||||
|
||||
## New operator-facing endpoints
|
||||
|
||||
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
|
||||
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
|
||||
users see only their group's halls).
|
||||
|
||||
| Method | Path | Permission | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
|
||||
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
|
||||
|
||||
## Permissions
|
||||
|
||||
The Selvvask surface uses these permissions only:
|
||||
|
||||
- `list_xlvask_usage_orders_own`
|
||||
- `list_xlvask_usage_orders_all`
|
||||
- `review_xlvask_usage_order`
|
||||
|
||||
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
|
||||
`superuser_xlvask_automation_activate` are not referenced anywhere in the
|
||||
slimmed surface.
|
||||
|
||||
## Persistence model
|
||||
|
||||
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
|
||||
columns — no migration required for the simplified flow.
|
||||
|
||||
`orders_o::selectByWashId(int|string $WashId)` and
|
||||
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
|
||||
the only integration points with the order pipeline.
|
||||
|
||||
## Tests
|
||||
|
||||
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
|
||||
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
|
||||
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
|
||||
|
||||
## Repo scope
|
||||
|
||||
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
|
||||
this session and will be handled in a follow-up PR.
|
||||
+16
-532
@@ -1746,9 +1746,9 @@ paths:
|
||||
- Subusers
|
||||
summary: Create a subuser registration
|
||||
description: |
|
||||
Starts a driver setup challenge using a company's CVR and a phone number. No company grant
|
||||
is created until the driver proves possession of the phone by completing the SMS setup link.
|
||||
The public response is uniform and never includes setup credentials or relationship state.
|
||||
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
|
||||
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
|
||||
sends a setup link by SMS for the user to complete registration.
|
||||
operationId: createSubuser
|
||||
security: []
|
||||
requestBody:
|
||||
@@ -1761,7 +1761,6 @@ paths:
|
||||
- cvr
|
||||
- phone_country_code
|
||||
- phone
|
||||
- g_recaptcha_response
|
||||
properties:
|
||||
cvr:
|
||||
type: integer
|
||||
@@ -1775,23 +1774,24 @@ paths:
|
||||
type: integer
|
||||
description: Phone number (4–15 digits, no leading +)
|
||||
example: 12345678
|
||||
g_recaptcha_response:
|
||||
type: string
|
||||
description: reCAPTCHA response token
|
||||
responses:
|
||||
'200':
|
||||
description: Uniform driver registration acknowledgement
|
||||
description: Subuser created (or pending setup) and company identified
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
cvr:
|
||||
type: integer
|
||||
example: 12345678
|
||||
customer_number:
|
||||
type: integer
|
||||
description: Matched e-conomic customer number
|
||||
example: 1000
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
|
||||
/subusers/{id}:
|
||||
get:
|
||||
@@ -2245,44 +2245,6 @@ paths:
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
# Authentication Endpoints
|
||||
/auth/limited-backoffice-login-grants/exchange:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Exchange a one-time limited-backoffice employee login grant
|
||||
description: Exchanges an unexpired, unrevoked grant exactly once for a regular employee bearer session. The grant is invalidated atomically before the session is returned.
|
||||
operationId: exchangeLimitedBackofficeEmployeeLoginGrant
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [grant]
|
||||
properties:
|
||||
grant:
|
||||
type: string
|
||||
pattern: '^lbg_[a-f0-9]{64}$'
|
||||
writeOnly: true
|
||||
responses:
|
||||
'200':
|
||||
description: Grant exchanged
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [employee_id, token]
|
||||
properties:
|
||||
employee_id: {type: integer}
|
||||
token:
|
||||
type: string
|
||||
description: Sensitive bearer token returned once by a successful grant exchange.
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -2617,156 +2579,6 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/account/deletion:
|
||||
get:
|
||||
tags:
|
||||
- Security
|
||||
summary: Describe account deletion requirements
|
||||
description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations.
|
||||
operationId: getAccountDeletion
|
||||
responses:
|
||||
'200':
|
||||
description: Account deletion requirements retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- principal_type
|
||||
- status
|
||||
- confirmation_phrase
|
||||
- password_required
|
||||
- two_factor_required
|
||||
- access_effect
|
||||
- retained_data_categories
|
||||
- privacy_policy_version
|
||||
properties:
|
||||
principal_type:
|
||||
type: string
|
||||
enum: [customer, subuser]
|
||||
status:
|
||||
type: string
|
||||
enum: [available, requested, processing, failed, manual_review, completed]
|
||||
confirmation_phrase:
|
||||
type: string
|
||||
enum: [SLET MIN KONTO]
|
||||
password_required:
|
||||
type: boolean
|
||||
description: False for authenticated passkey-only accounts that have no password.
|
||||
two_factor_required:
|
||||
type: boolean
|
||||
access_effect:
|
||||
type: string
|
||||
retained_data_categories:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
|
||||
privacy_policy_version:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
post:
|
||||
tags:
|
||||
- Security
|
||||
summary: Request deletion of the authenticated account
|
||||
description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history.
|
||||
operationId: requestAccountDeletion
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- confirmation
|
||||
- acknowledge_legal_retention
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
description: Required when password_required is true; omit for passkey-only accounts.
|
||||
passkey_challenge_token:
|
||||
type: string
|
||||
description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint.
|
||||
passkey_credential:
|
||||
type: object
|
||||
description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal.
|
||||
two_factor_code:
|
||||
type: string
|
||||
description: Required when two-factor authentication is enabled.
|
||||
confirmation:
|
||||
type: string
|
||||
enum: [SLET MIN KONTO]
|
||||
acknowledge_legal_retention:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
responses:
|
||||
'202':
|
||||
description: Deletion request accepted and account access revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- request_id
|
||||
- status
|
||||
- requested_at
|
||||
- access_revoked
|
||||
- retained_data_categories
|
||||
properties:
|
||||
request_id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
enum: [requested, processing, failed, manual_review, completed]
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
access_revoked:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
retained_data_categories:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'429':
|
||||
description: Too many deletion confirmation attempts
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/account/deletion/passkey/challenge:
|
||||
post:
|
||||
tags: [Security]
|
||||
summary: Create a deletion-specific WebAuthn challenge
|
||||
description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion.
|
||||
operationId: createAccountDeletionPasskeyChallenge
|
||||
responses:
|
||||
'200':
|
||||
description: Deletion-specific challenge created
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/auth/2fa/setup:
|
||||
post:
|
||||
tags:
|
||||
@@ -3158,107 +2970,6 @@ paths:
|
||||
properties:
|
||||
token: {type: string}
|
||||
|
||||
/limited-backoffice/employees/{employeeId}/login-grants:
|
||||
post:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Create or preflight a one-time employee login grant
|
||||
description: Requires limited-backoffice employee-management permissions and access to every department assigned to the employee. The bearer is deterministically derived under the server encryption key so an identical idempotent retry can recover the same unconsumed grant after a lost response; only its digest is stored.
|
||||
operationId: createLimitedBackofficeEmployeeLoginGrant
|
||||
parameters:
|
||||
- name: employeeId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer, minimum: 1}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
purpose:
|
||||
type: string
|
||||
enum: [limited_backoffice_employee_login]
|
||||
default: limited_backoffice_employee_login
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 900
|
||||
default: 300
|
||||
idempotency_key:
|
||||
type: string
|
||||
minLength: 16
|
||||
maxLength: 128
|
||||
writeOnly: true
|
||||
preflight:
|
||||
type: boolean
|
||||
default: false
|
||||
oneOf:
|
||||
- required: [idempotency_key]
|
||||
properties:
|
||||
preflight:
|
||||
type: boolean
|
||||
enum: [false]
|
||||
- required: [preflight]
|
||||
properties:
|
||||
preflight:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
responses:
|
||||
'200':
|
||||
description: Grant created, safely replayed for the same idempotency key, or request validated in preflight mode
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [employee_id, purpose, ttl_seconds, expires_at, one_time, preflight]
|
||||
properties:
|
||||
employee_id: {type: integer}
|
||||
purpose: {type: string}
|
||||
ttl_seconds: {type: integer}
|
||||
expires_at: {type: string, format: date-time}
|
||||
one_time: {type: boolean}
|
||||
preflight: {type: boolean}
|
||||
grant_id: {type: string, pattern: '^[a-f0-9]{32}$'}
|
||||
login_path:
|
||||
type: string
|
||||
description: Sensitive fragment URL returned only for a newly created or safely replayed grant.
|
||||
exchange_path: {type: string}
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
delete:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Revoke active one-time employee login grants
|
||||
operationId: revokeLimitedBackofficeEmployeeLoginGrants
|
||||
parameters:
|
||||
- name: employeeId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer, minimum: 1}
|
||||
responses:
|
||||
'200':
|
||||
description: Active grants revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [employee_id, revoked_count]
|
||||
properties:
|
||||
employee_id: {type: integer}
|
||||
revoked_count: {type: integer, minimum: 0}
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/limited-backoffice/employees/{employeeId}/login-link:
|
||||
post:
|
||||
tags:
|
||||
@@ -4296,19 +4007,6 @@ paths:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
auto_deactivation:
|
||||
type: object
|
||||
required: [at, timezone, label]
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
timezone:
|
||||
type: string
|
||||
example: Europe/Copenhagen
|
||||
label:
|
||||
type: string
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
put:
|
||||
@@ -4341,21 +4039,6 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
auto_deactivation:
|
||||
type: object
|
||||
required: [at, timezone, label]
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
timezone:
|
||||
type: string
|
||||
example: Europe/Copenhagen
|
||||
label:
|
||||
type: string
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
@@ -6759,14 +6442,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
'400':
|
||||
description: Invalid booking input or a product blocked by active customer rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
|
||||
- type: object
|
||||
put:
|
||||
tags:
|
||||
- Bookings
|
||||
@@ -8614,7 +8289,6 @@ paths:
|
||||
tags:
|
||||
- Orders
|
||||
summary: Create Stripe payment intent
|
||||
description: Creates a Stripe Terminal card payment intent with fixed 25% moms.
|
||||
operationId: createStripePaymentIntent
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -8626,6 +8300,7 @@ paths:
|
||||
properties:
|
||||
id: {type: integer}
|
||||
reader: {type: string}
|
||||
tax_percentage: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -13543,72 +13218,8 @@ paths:
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/PricingConflict'
|
||||
|
||||
/limited-backoffice/departments/{departmentId}/prices:
|
||||
get:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Get explicit limited-backoffice department prices
|
||||
description: Returns only explicit department prices and an opaque revision for optimistic concurrency.
|
||||
operationId: getLimitedBackofficeDepartmentPrices
|
||||
parameters:
|
||||
- name: departmentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Explicit department prices and current revision
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
put:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Replace explicit limited-backoffice department prices
|
||||
description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
|
||||
operationId: setLimitedBackofficeDepartmentPrices
|
||||
parameters:
|
||||
- name: departmentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Explicit department prices updated atomically
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/PricingConflict'
|
||||
|
||||
/limited-backoffice/departments/{departmentId}/customer-pricing:
|
||||
get:
|
||||
@@ -13657,7 +13268,7 @@ paths:
|
||||
tags:
|
||||
- Limited Backoffice
|
||||
summary: Replace limited-backoffice department customer pricing
|
||||
description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
|
||||
description: Replaces the complete override set for one customer in an assigned custom-only department.
|
||||
operationId: setLimitedBackofficeDepartmentCustomerPricing
|
||||
parameters:
|
||||
- name: departmentId
|
||||
@@ -13686,7 +13297,7 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/PricingConflict'
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
/superuser/department/variables:
|
||||
get:
|
||||
@@ -14461,14 +14072,6 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
PricingConflict:
|
||||
description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Error'
|
||||
- $ref: '#/components/schemas/PricingRevisionConflictResponse'
|
||||
Unauthorized:
|
||||
description: Unauthorized - Invalid or missing authentication token
|
||||
content:
|
||||
@@ -14662,8 +14265,6 @@ components:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
expected_revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
overrides:
|
||||
type: array
|
||||
items:
|
||||
@@ -14680,8 +14281,6 @@ components:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
expected_revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
overrides:
|
||||
type: array
|
||||
items:
|
||||
@@ -14712,7 +14311,6 @@ components:
|
||||
type: integer
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
|
||||
|
||||
DepartmentCustomerPricingOverride:
|
||||
allOf:
|
||||
@@ -14799,8 +14397,6 @@ components:
|
||||
type: integer
|
||||
display_name:
|
||||
type: string
|
||||
revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
overrides:
|
||||
type: array
|
||||
items:
|
||||
@@ -14812,95 +14408,6 @@ components:
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
PricingRevision:
|
||||
type: string
|
||||
pattern: '^[a-f0-9]{64}$'
|
||||
description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update.
|
||||
|
||||
PricingRevisionConflictResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [false]
|
||||
data:
|
||||
type: object
|
||||
required: [message, code, current_revision]
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
enum: [Pricing has changed. Reload and try again.]
|
||||
code:
|
||||
type: string
|
||||
enum: [pricing_revision_conflict]
|
||||
current_revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
LimitedBackofficeDepartmentPriceInput:
|
||||
type: object
|
||||
required: [product_id, price]
|
||||
properties:
|
||||
product_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
price:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
LimitedBackofficeDepartmentPricesUpdateRequest:
|
||||
type: object
|
||||
required: [prices]
|
||||
properties:
|
||||
expected_revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
prices:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput'
|
||||
|
||||
LimitedBackofficeDepartmentPricesResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
department:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
custom_pricing_only: { type: boolean }
|
||||
revision:
|
||||
$ref: '#/components/schemas/PricingRevision'
|
||||
categories:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
products:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
price: { type: integer }
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@@ -18548,13 +18055,11 @@ components:
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number]
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
@@ -18570,27 +18075,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||
|
||||
InvoicingPeriodCustomerMembership:
|
||||
type: object
|
||||
description: >-
|
||||
Lightweight customer marker returned for every non-active view
|
||||
bucket of the period response. Used by the front-end to render
|
||||
category indicator chips (e.g. "Faktura pr. ordre") regardless of
|
||||
which tab the user is currently looking at. Full customer-card
|
||||
data (transactions, invoice collections, queue, draft, meta)
|
||||
is intentionally omitted for non-active buckets; see
|
||||
InvoicingPeriodCustomer for the shape returned for the active
|
||||
bucket.
|
||||
additionalProperties: false
|
||||
required: [customer_number, membership_only]
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
membership_only:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
|
||||
InvoicingPeriodTransaction:
|
||||
type: object
|
||||
required: [id, booked, invoice_state]
|
||||
|
||||
+40
-49
@@ -1,55 +1,46 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
|
||||
linter: jetbrains/qodana-php:2026.1
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
profile:
|
||||
name: qodana.recommended
|
||||
name: qodana.starter
|
||||
|
||||
php:
|
||||
version: "8.2"
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
|
||||
bootstrap: |+
|
||||
set -eu
|
||||
composer --working-dir=services/nginx/app install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
|
||||
composer --working-dir=services/nginx/app/modules/washcertificates install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
|
||||
npm --prefix services/edge-agent ci --ignore-scripts
|
||||
npm --prefix services/edge-broker ci --ignore-scripts
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
|
||||
exclude:
|
||||
# This application is intentionally Composer-classmapped and keeps legacy snake_case
|
||||
# classes plus multiple local test doubles in single files; PSR path rules do not apply.
|
||||
- name: PhpIllegalPsrClassPathInspection
|
||||
paths:
|
||||
- services/nginx/app
|
||||
# Unit-test doubles intentionally bypass integration-heavy parent constructors.
|
||||
- name: PhpMissingParentConstructorInspection
|
||||
paths:
|
||||
- services/nginx/app/tests
|
||||
# These focused tests configure doubles through public fields before invoking behavior.
|
||||
- name: PhpObjectFieldsAreOnlyWrittenInspection
|
||||
paths:
|
||||
- services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php
|
||||
- services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php
|
||||
- services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php
|
||||
# API coverage markers are intentional statement-style calls in the Pest DSL.
|
||||
# Their return value is irrelevant; the call records route/scenario coverage.
|
||||
- name: PhpExpressionResultUnusedInspection
|
||||
paths:
|
||||
- services/nginx/app/tests/Api
|
||||
- name: All
|
||||
paths:
|
||||
- services/nginx/app/vendor
|
||||
- services/nginx/app/modules/washcertificates/vendor
|
||||
- services/nginx/app/build
|
||||
- services/nginx/app/.phpunit.cache
|
||||
- services/nginx/app/tests/Legacy
|
||||
- services/edge-agent/node_modules
|
||||
- services/edge-broker/node_modules
|
||||
- services/edge-agent/dist
|
||||
- documentation/generated
|
||||
- documentation/topics/generated
|
||||
- documentation/_build
|
||||
- documentation/_site_rebuild_20260317
|
||||
- docs_bird_voice_calls.html
|
||||
- .tmp
|
||||
- .openclaw
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
|
||||
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||
linter: jetbrains/qodana-php:2025.3
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const WD = __DIR__ . '/../services/nginx/app';
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/classes/account_deletion_schema_bootstrap.php';
|
||||
|
||||
$response = null;
|
||||
$db = new \classes\db($CONFIG_DB);
|
||||
$db->connect();
|
||||
$command = $argv[1] ?? 'check';
|
||||
|
||||
if ($command === 'apply') {
|
||||
if (($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
\classes\account_deletion_schema_bootstrap::apply();
|
||||
}
|
||||
|
||||
if (!in_array($command, ['check', 'apply'], true)) {
|
||||
fwrite(STDERR, "Usage: scripts/account-deletion-schema.php check|apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$status = \classes\account_deletion_schema_bootstrap::check();
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit($status['ready'] ? 0 : 1);
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$command = $argv[1] ?? 'check';
|
||||
if (!in_array($command, ['check', 'apply', 'webhooks-check', 'webhooks-apply'], true)) {
|
||||
fwrite(
|
||||
STDERR,
|
||||
"Usage: scripts/bird-control-plane-activate.php check|apply|webhooks-check|webhooks-apply [--yes]\n"
|
||||
);
|
||||
exit(2);
|
||||
}
|
||||
if (in_array($command, ['apply', 'webhooks-apply'], true) && ($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing Bird activation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||
if (!is_file($appDirectory . '/config.php')) {
|
||||
$appDirectory = dirname(__DIR__);
|
||||
}
|
||||
define('WD', $appDirectory);
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
|
||||
require_once WD . '/modules/bird/classes/bird_webhook_subscription_reconciler.php';
|
||||
|
||||
try {
|
||||
$pdo = \classes\db::getPDO();
|
||||
if (str_starts_with($command, 'webhooks-')) {
|
||||
$reconciler = new \bird\classes\bird_webhook_subscription_reconciler($pdo);
|
||||
$organizationId = trim((string)(getenv('BIRD_ORGANIZATION_ID') ?: ''));
|
||||
$status = $command === 'webhooks-apply'
|
||||
? $reconciler->apply($organizationId)
|
||||
: $reconciler->check($organizationId);
|
||||
} else {
|
||||
$activator = new \bird\classes\bird_control_plane_activator($pdo);
|
||||
$status = $command === 'apply' ? $activator->apply([
|
||||
'controlPlaneToken' => trim((string)(getenv('BIRD_CONTROL_PLANE_TOKEN') ?: '')),
|
||||
'webhookSigningKey' => trim((string)(getenv('BIRD_WEBHOOK_SIGNING_KEY') ?: '')),
|
||||
'participantId' => trim((string)(getenv('BIRD_PARTICIPANT_ID') ?: '')),
|
||||
]) : $activator->check();
|
||||
}
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[bird-control-plane-activate] Failed: ' . get_class($throwable));
|
||||
fwrite(STDOUT, json_encode([
|
||||
'ready' => false,
|
||||
'errorCode' => 'bird_activation_failed',
|
||||
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||
if (!is_file($appDirectory . '/config.php')) {
|
||||
$appDirectory = dirname(__DIR__);
|
||||
}
|
||||
define('WD', $appDirectory);
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_auto_activation.php';
|
||||
|
||||
try {
|
||||
$status = (new \bird\classes\bird_control_plane_auto_activation(
|
||||
\classes\db::getPDO()
|
||||
))->run();
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[bird-control-plane-auto-activate] Failed: ' . get_class($throwable));
|
||||
fwrite(STDOUT, '{"ready":false,"errorCode":"bird_auto_activation_failed"}' . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'
|
||||
status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'
|
||||
expected_algorithm='RSA-OAEP-256'
|
||||
expected_fingerprint='6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21'
|
||||
private_key='/home/jeppe/.openclaw/credentials/bird.bootstrap-private.pem'
|
||||
credential_dir='/home/jeppe/.openclaw/credentials'
|
||||
destination="$credential_dir/bird.gateway-token"
|
||||
|
||||
umask 077
|
||||
mkdir -p "$credential_dir"
|
||||
envelope_file="$(mktemp "$credential_dir/.bird-bootstrap-envelope.XXXXXX")"
|
||||
candidate_file="$(mktemp "$credential_dir/.bird-gateway-token.XXXXXX")"
|
||||
payload_file="$(mktemp "$credential_dir/.bird-bootstrap-payload.XXXXXX")"
|
||||
status_file="$(mktemp "$credential_dir/.bird-bootstrap-status.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -f "$envelope_file" "$candidate_file" "$payload_file" "$status_file"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
test -r "$private_key"
|
||||
test "$(stat -c '%a' "$private_key")" = '600'
|
||||
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||
--max-time 30 "$bootstrap_url" > "$envelope_file"
|
||||
|
||||
test "$(jq -r '.success // false' "$envelope_file")" = 'true'
|
||||
test "$(jq -r '.data.algorithm // empty' "$envelope_file")" = "$expected_algorithm"
|
||||
test "$(jq -r '.data.keyFingerprint // empty' "$envelope_file")" = "$expected_fingerprint"
|
||||
jq -e '.data | keys == ["algorithm","ciphertext","keyFingerprint","tokenVersion","updatedAt"]' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.tokenVersion | type == "number" and . >= 1 and floor == .' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.updatedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.ciphertext | type == "string" and length == 512 and test("^[A-Za-z0-9+/]{512}$")' \
|
||||
"$envelope_file" >/dev/null
|
||||
|
||||
jq -r '.data.ciphertext' "$envelope_file" \
|
||||
| base64 -d \
|
||||
| openssl pkeyutl -decrypt -inkey "$private_key" \
|
||||
-pkeyopt rsa_padding_mode:oaep \
|
||||
-pkeyopt rsa_oaep_md:sha256 \
|
||||
-pkeyopt rsa_mgf1_md:sha256 > "$payload_file"
|
||||
|
||||
jq -e '. | keys == ["algorithm","keyFingerprint","token","tokenVersion","updatedAt"]' \
|
||||
"$payload_file" >/dev/null
|
||||
test "$(jq -r '.algorithm // empty' "$payload_file")" = "$expected_algorithm"
|
||||
test "$(jq -r '.keyFingerprint // empty' "$payload_file")" = "$expected_fingerprint"
|
||||
test "$(jq -r '.tokenVersion // empty' "$payload_file")" = \
|
||||
"$(jq -r '.data.tokenVersion' "$envelope_file")"
|
||||
test "$(jq -r '.updatedAt // empty' "$payload_file")" = \
|
||||
"$(jq -r '.data.updatedAt' "$envelope_file")"
|
||||
jq -j '.token' "$payload_file" > "$candidate_file"
|
||||
|
||||
test "$(wc -c < "$candidate_file")" = '64'
|
||||
grep -Eq '^[A-Za-z0-9_-]{64}$' "$candidate_file"
|
||||
chmod 600 "$candidate_file"
|
||||
|
||||
token="$(cat "$candidate_file")"
|
||||
{
|
||||
printf 'url = "%s"\n' "$status_url"
|
||||
printf 'proto = "=https"\n'
|
||||
printf 'tlsv1.2\n'
|
||||
printf 'fail\nsilent\nshow-error\n'
|
||||
printf 'max-time = 30\n'
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$token"
|
||||
} | curl --config - > "$status_file"
|
||||
unset token
|
||||
|
||||
jq -e '.success == true and .data.enabled == true and .data.webhookConfigured == true' \
|
||||
"$status_file" >/dev/null
|
||||
mv -f "$candidate_file" "$destination"
|
||||
chmod 600 "$destination"
|
||||
trap - EXIT HUP INT TERM
|
||||
rm -f "$envelope_file" "$payload_file" "$status_file"
|
||||
printf 'Bird gateway credential bootstrapped and authenticated.\n'
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const WD = __DIR__ . '/../services/nginx/app';
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
|
||||
|
||||
$command = $argv[1] ?? 'check';
|
||||
if (!in_array($command, ['check', 'apply'], true)) {
|
||||
fwrite(STDERR, "Usage: scripts/bird-control-plane-schema.php check|apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$pdo = \classes\db::getPDO();
|
||||
if ($command === 'apply') {
|
||||
if (($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
\bird\classes\bird_control_plane_schema_bootstrap::apply($pdo);
|
||||
}
|
||||
|
||||
$status = \bird\classes\bird_control_plane_schema_bootstrap::check($pdo);
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit($status['ready'] ? 0 : 1);
|
||||
@@ -144,7 +144,7 @@ function requestJson({ method = "GET", port, path: requestPath, body = null, hea
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded;
|
||||
let decoded = {};
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
|
||||
@@ -95,7 +95,7 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, "");
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
|
||||
return true;
|
||||
}
|
||||
|
||||
return /already exists|already connected/i.test(stderr);
|
||||
if (/already exists|already connected/i.test(stderr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
|
||||
@@ -829,7 +833,7 @@ async function main() {
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot;
|
||||
let operationSnapshot = null;
|
||||
try {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
@@ -955,10 +959,9 @@ async function main() {
|
||||
allowFailure: true,
|
||||
}).catch(() => {});
|
||||
|
||||
const fixtureAuthToken = fixture?.auth_token;
|
||||
if (gatewayId !== null && fixtureAuthToken) {
|
||||
if (gatewayId !== null && fixture?.auth_token) {
|
||||
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
||||
token: String(fixtureAuthToken),
|
||||
token: String(fixture.auth_token),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -405,10 +405,6 @@ def render_api_reference_topic() -> str:
|
||||
' title="API Reference" id="API-Reference">\n'
|
||||
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
||||
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
||||
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
||||
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
||||
"successful HTTP request handled by the broker container and defaults to the container's "
|
||||
"start time when no request has been processed yet.</p>\n"
|
||||
"</topic>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -146,13 +146,6 @@ tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
docker-compose.yml \
|
||||
docker-compose.example.yml \
|
||||
docker-compose.prod.standalone.yml \
|
||||
scripts/bird-control-plane-auto-activate.php \
|
||||
scripts/bird-control-plane-bootstrap-local.sh \
|
||||
scripts/xlvask-automation-migrate.php \
|
||||
services/coolify/api/start.sh \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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";
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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
|
||||
+3
-3
@@ -4,9 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
-37
@@ -40,10 +40,6 @@ class Receiver extends Writable {
|
||||
* extensions
|
||||
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
|
||||
* client or server mode
|
||||
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=0] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||
* not to skip UTF-8 validation for text and close messages
|
||||
@@ -58,8 +54,6 @@ class Receiver extends Writable {
|
||||
this._binaryType = options.binaryType || BINARY_TYPES[0];
|
||||
this._extensions = options.extensions || {};
|
||||
this._isServer = !!options.isServer;
|
||||
this._maxBufferedChunks = options.maxBufferedChunks | 0;
|
||||
this._maxFragments = options.maxFragments | 0;
|
||||
this._maxPayload = options.maxPayload | 0;
|
||||
this._skipUTF8Validation = !!options.skipUTF8Validation;
|
||||
this[kWebSocket] = undefined;
|
||||
@@ -77,7 +71,6 @@ class Receiver extends Writable {
|
||||
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
this._errored = false;
|
||||
@@ -96,22 +89,6 @@ class Receiver extends Writable {
|
||||
_write(chunk, encoding, cb) {
|
||||
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
|
||||
|
||||
if (
|
||||
this._maxBufferedChunks > 0 &&
|
||||
this._buffers.length >= this._maxBufferedChunks
|
||||
) {
|
||||
cb(
|
||||
this.createError(
|
||||
RangeError,
|
||||
'Too many buffered chunks',
|
||||
false,
|
||||
1008,
|
||||
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._bufferedBytes += chunk.length;
|
||||
this._buffers.push(chunk);
|
||||
this.startLoop(cb);
|
||||
@@ -501,19 +478,6 @@ class Receiver extends Writable {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
|
||||
const error = this.createError(
|
||||
RangeError,
|
||||
'Too many message fragments',
|
||||
false,
|
||||
1008,
|
||||
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||
);
|
||||
|
||||
cb(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._compressed) {
|
||||
this._state = INFLATING;
|
||||
this.decompress(data, cb);
|
||||
@@ -586,7 +550,6 @@ class Receiver extends Writable {
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._fragmented = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
if (this._opcode === 2) {
|
||||
|
||||
+1
-6
@@ -4,9 +4,6 @@
|
||||
|
||||
const { Duplex } = require('stream');
|
||||
const { randomFillSync } = require('crypto');
|
||||
const {
|
||||
types: { isUint8Array }
|
||||
} = require('util');
|
||||
|
||||
const PerMessageDeflate = require('./permessage-deflate');
|
||||
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
|
||||
@@ -203,10 +200,8 @@ class Sender {
|
||||
|
||||
if (typeof data === 'string') {
|
||||
buf.write(data, 2);
|
||||
} else if (isUint8Array(data)) {
|
||||
buf.set(data, 2);
|
||||
} else {
|
||||
throw new TypeError('Second argument must be a string or a Uint8Array');
|
||||
buf.set(data, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-8
@@ -43,10 +43,6 @@ class WebSocketServer extends EventEmitter {
|
||||
* called
|
||||
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
||||
* @param {String} [options.host] The hostname where to bind the server
|
||||
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||
* size
|
||||
* @param {Boolean} [options.noServer=false] Enable no server mode
|
||||
@@ -69,8 +65,6 @@ class WebSocketServer extends EventEmitter {
|
||||
options = {
|
||||
allowSynchronousEvents: true,
|
||||
autoPong: true,
|
||||
maxBufferedChunks: 256 * 1024,
|
||||
maxFragments: 16 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
skipUTF8Validation: false,
|
||||
perMessageDeflate: false,
|
||||
@@ -430,8 +424,6 @@ class WebSocketServer extends EventEmitter {
|
||||
|
||||
ws.setSocket(socket, head, {
|
||||
allowSynchronousEvents: this.options.allowSynchronousEvents,
|
||||
maxBufferedChunks: this.options.maxBufferedChunks,
|
||||
maxFragments: this.options.maxFragments,
|
||||
maxPayload: this.options.maxPayload,
|
||||
skipUTF8Validation: this.options.skipUTF8Validation
|
||||
});
|
||||
|
||||
-14
@@ -201,10 +201,6 @@ class WebSocket extends EventEmitter {
|
||||
* multiple times in the same tick
|
||||
* @param {Function} [options.generateMask] The function used to generate the
|
||||
* masking key
|
||||
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=0] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=0] The maximum allowed message size
|
||||
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||
* not to skip UTF-8 validation for text and close messages
|
||||
@@ -216,8 +212,6 @@ class WebSocket extends EventEmitter {
|
||||
binaryType: this.binaryType,
|
||||
extensions: this._extensions,
|
||||
isServer: this._isServer,
|
||||
maxBufferedChunks: options.maxBufferedChunks,
|
||||
maxFragments: options.maxFragments,
|
||||
maxPayload: options.maxPayload,
|
||||
skipUTF8Validation: options.skipUTF8Validation
|
||||
});
|
||||
@@ -646,10 +640,6 @@ module.exports = WebSocket;
|
||||
* masking key
|
||||
* @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
|
||||
* handshake request
|
||||
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||
* size
|
||||
* @param {Number} [options.maxRedirects=10] The maximum number of redirects
|
||||
@@ -670,8 +660,6 @@ function initAsClient(websocket, address, protocols, options) {
|
||||
autoPong: true,
|
||||
closeTimeout: CLOSE_TIMEOUT,
|
||||
protocolVersion: protocolVersions[1],
|
||||
maxBufferedChunks: 256 * 1024,
|
||||
maxFragments: 16 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
skipUTF8Validation: false,
|
||||
perMessageDeflate: true,
|
||||
@@ -1029,8 +1017,6 @@ function initAsClient(websocket, address, protocols, options) {
|
||||
websocket.setSocket(socket, head, {
|
||||
allowSynchronousEvents: opts.allowSynchronousEvents,
|
||||
generateMask: opts.generateMask,
|
||||
maxBufferedChunks: opts.maxBufferedChunks,
|
||||
maxFragments: opts.maxFragments,
|
||||
maxPayload: opts.maxPayload,
|
||||
skipUTF8Validation: opts.skipUTF8Validation
|
||||
});
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws",
|
||||
"version": "8.21.1",
|
||||
"version": "8.20.0",
|
||||
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
||||
"keywords": [
|
||||
"HyBi",
|
||||
@@ -66,9 +66,5 @@
|
||||
"nyc": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"utf-8-validate": "^6.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"bufferutil": true,
|
||||
"utf-8-validate": true
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -6,13 +6,13 @@
|
||||
"": {
|
||||
"name": "truckwash-edge-broker",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.1"
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"test:live": "node --test live/live-smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.1"
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function resolveManagerUrl(options = {}) {
|
||||
return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || "");
|
||||
}
|
||||
|
||||
function resolveAuthMode(options = {}) {
|
||||
function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
if (options.authMode) {
|
||||
return options.authMode;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = resolveSharedSecret(options);
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
|
||||
|
||||
@@ -189,8 +189,6 @@ export function createBrokerServer(options = {}) {
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
const containerStartedAt = currentTimestamp();
|
||||
let lastActivityAt = containerStartedAt;
|
||||
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
@@ -482,7 +480,6 @@ export function createBrokerServer(options = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
lastActivityAt = currentTimestamp();
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -491,7 +488,6 @@ export function createBrokerServer(options = {}) {
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
lastActivityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1087,10 +1083,6 @@ export function createBrokerServer(options = {}) {
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
containerStartedAt,
|
||||
get lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,10 +219,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
assert.equal(typeof healthJson.lastActivityAt, "string");
|
||||
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
|
||||
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
@@ -251,41 +247,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker updates lastActivityAt after each successful request", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
|
||||
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const firstJson = await firstResponse.json();
|
||||
const firstActivityAt = broker.state.lastActivityAt;
|
||||
|
||||
assert.equal(typeof firstJson.lastActivityAt, "string");
|
||||
assert.equal(firstJson.lastActivityAt, firstActivityAt);
|
||||
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
|
||||
assert.ok(broker.state.lastActivityAt > firstActivityAt);
|
||||
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const secondJson = await secondResponse.json();
|
||||
|
||||
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
|
||||
@@ -39,8 +39,8 @@ test("traefik does not expose a dedicated public edge broker port", () => {
|
||||
test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
@@ -55,8 +55,8 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
test("example docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||
@@ -65,8 +65,8 @@ test("example docker compose routes edge broker traffic through traefik", () =>
|
||||
test("standalone production compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-manager\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
@@ -78,8 +78,8 @@ test("standalone production compose routes edge broker traffic through traefik",
|
||||
|
||||
test("compose config does not provide insecure broker secret defaults", () => {
|
||||
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
|
||||
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
|
||||
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ test("base docker compose wires the broker into each php worker", () => {
|
||||
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
||||
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class account_deletion_http_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Explicit account-deletion schema management.
|
||||
*
|
||||
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
|
||||
* are deliberately limited to the read-only check().
|
||||
*/
|
||||
class account_deletion_schema_bootstrap
|
||||
{
|
||||
/** @return array{ready:bool,missing:array<int,string>} */
|
||||
public static function check(): array
|
||||
{
|
||||
global $db;
|
||||
$missing = [];
|
||||
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'table:' . $table;
|
||||
}
|
||||
}
|
||||
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:' . $table . '.' . $column;
|
||||
}
|
||||
}
|
||||
if (!in_array('table:account_deletion_requests', $missing, true)) {
|
||||
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
|
||||
}
|
||||
}
|
||||
return ['ready' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
|
||||
public static function apply(): void
|
||||
{
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
|
||||
}
|
||||
global $db;
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
principal_type VARCHAR(16) NOT NULL,
|
||||
principal_id BIGINT UNSIGNED NOT NULL,
|
||||
customer_number_snapshot INT NULL,
|
||||
active_principal_key VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'requested',
|
||||
policy_version VARCHAR(32) NOT NULL,
|
||||
retained_data_json LONGTEXT NOT NULL,
|
||||
request_ip VARCHAR(45) NULL,
|
||||
request_user_agent VARCHAR(512) NULL,
|
||||
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
failure_code VARCHAR(191) NULL,
|
||||
requested_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
next_attempt_at DATETIME NULL,
|
||||
manual_review_required_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_account_deletion_request_id (request_id),
|
||||
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
|
||||
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
|
||||
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
|
||||
throttle_key CHAR(64) NOT NULL,
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
window_started_at DATETIME NOT NULL,
|
||||
blocked_until DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
available_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
delivered_at DATETIME NULL,
|
||||
last_error VARCHAR(191) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
|
||||
INDEX idx_account_deletion_outbox_delivery (status, available_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
|
||||
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
|
||||
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
|
||||
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
|
||||
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
|
||||
}
|
||||
|
||||
private static function execute(string $sql): void
|
||||
{
|
||||
global $db;
|
||||
if ($db->query($sql) === false) {
|
||||
throw new \RuntimeException('Account deletion schema operation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/account_deletion_service.php';
|
||||
|
||||
use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
@@ -71,10 +69,6 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_2fa_token(int $id, string $type): string
|
||||
{
|
||||
$principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer';
|
||||
if (account_deletion_service::principalIsBlocked($principalType, $id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a temporary 2FA token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($id, $token, $type);
|
||||
@@ -106,9 +100,6 @@ class authentication implements authentication_i
|
||||
throw new \Exception('User not found for customer number: ' . $customer_number);
|
||||
}
|
||||
$user_id = $user->id;
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Save the token in the database
|
||||
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
||||
return $token;
|
||||
@@ -116,9 +107,6 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_token_by_user_id(int $user_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -128,9 +116,6 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_employee_token(int $employee_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $employee_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -138,26 +123,13 @@ class authentication implements authentication_i
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function create_impersonation_token(int $target_user_id, int $actor_user_id): string
|
||||
{
|
||||
if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id);
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate_token(string $token): bool
|
||||
{
|
||||
// First: try validating as a classic user auth token
|
||||
try {
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
|
||||
return !account_deletion_service::principalIsBlocked(
|
||||
'customer',
|
||||
(int)$dbToken->user_id->value()
|
||||
);
|
||||
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore and continue to subuser session validation
|
||||
@@ -165,7 +137,7 @@ class authentication implements authentication_i
|
||||
// Fallback: try validating as a subuser session token
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
if ($subuser !== null) {
|
||||
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -196,10 +168,7 @@ class authentication implements authentication_i
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
@@ -208,11 +177,6 @@ class authentication implements authentication_i
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function isClassicAuthTokenType(string $type): bool
|
||||
{
|
||||
return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:');
|
||||
}
|
||||
|
||||
public function get_plate_scanner(): plate_scanners_o|false
|
||||
{
|
||||
// Get the token from the headers
|
||||
@@ -263,9 +227,6 @@ class authentication implements authentication_i
|
||||
if ($subuser === null) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
return false;
|
||||
}
|
||||
$customerNumberContext = null;
|
||||
if (isset($headers['X-Customer-Number'])) {
|
||||
$customerNumberContext = (int)$headers['X-Customer-Number'];
|
||||
|
||||
@@ -212,7 +212,19 @@ class bird implements bird_i
|
||||
throw new Exception('cURL error: ' . $err);
|
||||
}
|
||||
curl_close($ch);
|
||||
// Debug slack
|
||||
$data = json_decode($body, true) ?? [];
|
||||
$resp = $resp === false ? 'cURL error with no response' : $resp;
|
||||
$slack_debug_message = "*Bird API Request Debug:*"
|
||||
. "\nEndpoint: $url"
|
||||
. "\nMethod: $method"
|
||||
. "\nStatus: $code"
|
||||
. "\nPayload Keys: " . implode(',', array_keys($data))
|
||||
. "\nResponse: $resp";
|
||||
|
||||
// Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
|
||||
$slack = new \classes\slack();
|
||||
$slack->send_message($slack_debug_message);
|
||||
return [
|
||||
'status_code' => (int)$code,
|
||||
'body' => $resp,
|
||||
@@ -1061,3 +1073,4 @@ class bird implements bird_i
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace classes;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/cors_policy.php';
|
||||
|
||||
class coolify_manager
|
||||
{
|
||||
private const KINDS = ['database', 'redis', 'minio'];
|
||||
@@ -1072,9 +1070,7 @@ class coolify_manager
|
||||
$targetPublicUrl,
|
||||
$resourceUuid,
|
||||
self::resourceFirstExposedPort($resource, $target),
|
||||
$resource['custom_labels'] ?? null,
|
||||
$app,
|
||||
self::gatewayRouteTargetCorsConfig($target)
|
||||
$resource['custom_labels'] ?? null
|
||||
);
|
||||
$update = $resourceType === 'service'
|
||||
? $client->updateService($resourceUuid, $updatePayload)
|
||||
@@ -2424,9 +2420,7 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid = '',
|
||||
?int $port = null,
|
||||
mixed $existingLabels = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
mixed $existingLabels = null
|
||||
): array
|
||||
{
|
||||
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
|
||||
@@ -2441,9 +2435,7 @@ class coolify_manager
|
||||
$publicUrl,
|
||||
$resourceUuid,
|
||||
$routePort,
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl),
|
||||
$app,
|
||||
$corsConfig
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl)
|
||||
);
|
||||
if ($labels !== []) {
|
||||
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
||||
@@ -2468,49 +2460,6 @@ class coolify_manager
|
||||
];
|
||||
}
|
||||
|
||||
private static function gatewayRouteTargetCorsConfig(array $target): string
|
||||
{
|
||||
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
|
||||
$configured = null;
|
||||
|
||||
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
|
||||
$env = $context[$key] ?? null;
|
||||
if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) {
|
||||
$configured = (string)$env['CORS'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['coolify_env_file', 'env'] as $key) {
|
||||
$raw = $context[$key] ?? null;
|
||||
if (!is_string($raw)) {
|
||||
continue;
|
||||
}
|
||||
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
|
||||
$line = trim((string)$line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$envKey, $value] = explode('=', $line, 2);
|
||||
if (trim($envKey) === 'CORS') {
|
||||
$configured = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($configured === null) {
|
||||
$runtimeValue = getenv('CORS');
|
||||
if ($runtimeValue !== false) {
|
||||
$configured = $runtimeValue;
|
||||
} elseif (array_key_exists('CORS', $_ENV ?? [])) {
|
||||
$configured = (string)$_ENV['CORS'];
|
||||
} elseif (array_key_exists('CORS', $_SERVER ?? [])) {
|
||||
$configured = (string)$_SERVER['CORS'];
|
||||
}
|
||||
}
|
||||
|
||||
return cors_policy::withRequiredOrigins((string)($configured ?? ''));
|
||||
}
|
||||
|
||||
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
|
||||
{
|
||||
if ($port === null || $port <= 0) {
|
||||
@@ -2534,9 +2483,7 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid,
|
||||
?int $port = null,
|
||||
?string $certResolver = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
?string $certResolver = null
|
||||
): array
|
||||
{
|
||||
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
|
||||
@@ -2561,7 +2508,6 @@ class coolify_manager
|
||||
$certResolver = trim((string)($certResolver ?? ''));
|
||||
$httpLabel = 'http-0-' . $resourceUuid;
|
||||
$httpsLabel = 'https-0-' . $resourceUuid;
|
||||
$isApi = strtolower(trim($app)) === 'api';
|
||||
$labels = [
|
||||
'traefik.enable=true',
|
||||
'traefik.http.middlewares.gzip.compress=true',
|
||||
@@ -2575,18 +2521,12 @@ class coolify_manager
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
||||
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
||||
}
|
||||
$httpsMiddlewares = [];
|
||||
if ($isApi) {
|
||||
$corsMiddleware = "{$httpsLabel}-cors";
|
||||
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
|
||||
$httpsMiddlewares[] = $corsMiddleware;
|
||||
}
|
||||
if ($path !== '/') {
|
||||
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
||||
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
||||
} else {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
||||
}
|
||||
$httpsMiddlewares[] = 'gzip';
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
||||
if ($certResolver !== '') {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
||||
|
||||
@@ -29,7 +29,6 @@ class cors_policy
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
'capacitor://localhost',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
@@ -39,7 +38,7 @@ class cors_policy
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -49,7 +48,7 @@ class cors_policy
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -59,27 +58,6 @@ class cors_policy
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
public static function normalizeRequestOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $disallowedPart) {
|
||||
if (array_key_exists($disallowedPart, $parts)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return self::normalizeOrigin($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
@@ -88,39 +66,6 @@ class cors_policy
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
|
||||
{
|
||||
$middlewareName = trim($middlewareName);
|
||||
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowedHeaders = array_values(array_filter(
|
||||
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
|
||||
static fn(string $header): bool => $header !== '' && $header !== '*'
|
||||
));
|
||||
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
|
||||
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
|
||||
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
$originLabel = $allowedOrigins === ['*']
|
||||
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
|
||||
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
|
||||
|
||||
return [
|
||||
"{$prefix}.accesscontrolallowcredentials=true",
|
||||
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
|
||||
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
|
||||
$originLabel,
|
||||
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
|
||||
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
|
||||
"{$prefix}.addvaryheader=true",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
@@ -160,8 +105,8 @@ class cors_policy
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
if ($origin === '') {
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -174,7 +119,7 @@ class cors_policy
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -227,15 +227,10 @@ class cron_scheduler
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun($definition, $run_id, $started, $scheduled_for);
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function executeClaimedRun(
|
||||
cron_task_definition $definition,
|
||||
int $run_id,
|
||||
float $started,
|
||||
?string $scheduled_for = null
|
||||
): array
|
||||
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
|
||||
{
|
||||
$status = 'succeeded';
|
||||
$summary = [];
|
||||
@@ -277,7 +272,7 @@ class cron_scheduler
|
||||
|
||||
$completed_at = date('Y-m-d H:i:s', (int)$completed);
|
||||
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
|
||||
$this->releaseLock($definition, $status, $error_message, $completed_at, $scheduled_for);
|
||||
$this->releaseLock($definition, $status, $error_message, $completed_at);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
@@ -446,12 +441,7 @@ class cron_scheduler
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun(
|
||||
$definition,
|
||||
$run_id,
|
||||
$started,
|
||||
isset($queuedRun['scheduled_for']) ? (string)$queuedRun['scheduled_for'] : null
|
||||
);
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
|
||||
@@ -489,13 +479,7 @@ class cron_scheduler
|
||||
);
|
||||
}
|
||||
|
||||
private function releaseLock(
|
||||
cron_task_definition $definition,
|
||||
string $status,
|
||||
?string $error_message,
|
||||
string $completed_at,
|
||||
?string $scheduled_for = null
|
||||
): void
|
||||
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
|
||||
{
|
||||
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
|
||||
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
|
||||
@@ -503,12 +487,7 @@ class cron_scheduler
|
||||
$schedule = $definition->schedule;
|
||||
}
|
||||
|
||||
// Automatic runs stay anchored to their intended schedule slot. Anchoring
|
||||
// to completion time causes every task to drift by its execution time.
|
||||
$scheduleAnchor = $scheduled_for !== null && strtotime($scheduled_for) !== false
|
||||
? $scheduled_for
|
||||
: $completed_at;
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $scheduleAnchor, time());
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
|
||||
if ($status !== 'succeeded') {
|
||||
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
|
||||
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
|
||||
|
||||
@@ -84,8 +84,6 @@ class cron_schema_bootstrap
|
||||
last_heartbeat_at DATETIME NULL,
|
||||
last_loop_started_at DATETIME NULL,
|
||||
last_loop_finished_at DATETIME NULL,
|
||||
last_loop_gap_seconds INT UNSIGNED NULL,
|
||||
consecutive_minute_loops INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
stopped_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -95,8 +93,6 @@ class cron_schema_bootstrap
|
||||
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
self::ensureColumn('cron_worker_state', 'last_loop_gap_seconds', 'INT UNSIGNED NULL AFTER last_loop_finished_at');
|
||||
self::ensureColumn('cron_worker_state', 'consecutive_minute_loops', 'INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_loop_gap_seconds');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
private cron_scheduler $scheduler;
|
||||
private string $worker_id;
|
||||
private string $name;
|
||||
@@ -44,7 +39,6 @@ class cron_worker
|
||||
$this->heartbeat('starting', 0, 0, null, true);
|
||||
|
||||
while (!$this->should_stop) {
|
||||
$pollStarted = microtime(true);
|
||||
$result = $this->tick();
|
||||
$this->writeStatusLine($result);
|
||||
|
||||
@@ -53,7 +47,7 @@ class cron_worker
|
||||
break;
|
||||
}
|
||||
|
||||
$this->sleepUntilNextPoll($pollStarted + $this->poll_seconds);
|
||||
$this->sleepUntilNextPoll();
|
||||
}
|
||||
|
||||
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||
@@ -125,14 +119,13 @@ class cron_worker
|
||||
});
|
||||
}
|
||||
|
||||
private function sleepUntilNextPoll(float $nextPollAt): void
|
||||
private function sleepUntilNextPoll(): void
|
||||
{
|
||||
while (!$this->should_stop) {
|
||||
$remaining = $nextPollAt - microtime(true);
|
||||
if ($remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
usleep((int)(min(1.0, $remaining) * 1000000));
|
||||
$remaining = $this->poll_seconds;
|
||||
while ($remaining > 0 && !$this->should_stop) {
|
||||
$sleep = min(1, $remaining);
|
||||
sleep($sleep);
|
||||
$remaining -= $sleep;
|
||||
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||
$this->heartbeat('running');
|
||||
}
|
||||
@@ -169,19 +162,18 @@ class cron_worker
|
||||
$errorSql = $this->nullableSql($error);
|
||||
$loopStarted = $this->nullableSql($loopStartedAt);
|
||||
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
|
||||
$nowSql = $this->sql($now);
|
||||
|
||||
$this->query(
|
||||
"INSERT INTO cron_worker_state (
|
||||
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
|
||||
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
|
||||
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
|
||||
last_loop_finished_at, last_loop_gap_seconds, consecutive_minute_loops, stopped_at
|
||||
last_loop_finished_at, stopped_at
|
||||
) VALUES (
|
||||
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||
$staleRunCount, $errorSql, $nowSql, $nowSql, $loopStarted,
|
||||
$nowSql, NULL, " . ($loopStartedAt !== null ? '1' : '0') . ", $stoppedAt
|
||||
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
|
||||
$this->sql($now), $stoppedAt
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
@@ -199,17 +191,6 @@ class cron_worker
|
||||
last_stale_run_count = VALUES(last_stale_run_count),
|
||||
last_error = VALUES(last_error),
|
||||
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||
last_loop_gap_seconds = CASE
|
||||
WHEN VALUES(last_loop_started_at) IS NULL OR last_loop_started_at IS NULL THEN last_loop_gap_seconds
|
||||
ELSE GREATEST(0, TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)))
|
||||
END,
|
||||
consecutive_minute_loops = CASE
|
||||
WHEN VALUES(last_loop_started_at) IS NULL THEN consecutive_minute_loops
|
||||
WHEN last_loop_started_at IS NULL THEN 1
|
||||
WHEN TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)) BETWEEN 0 AND 60
|
||||
THEN consecutive_minute_loops + 1
|
||||
ELSE 1
|
||||
END,
|
||||
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
|
||||
last_loop_finished_at = VALUES(last_loop_finished_at),
|
||||
stopped_at = VALUES(stopped_at)"
|
||||
@@ -222,17 +203,6 @@ class cron_worker
|
||||
$heartbeatTs = strtotime($heartbeatAt);
|
||||
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
|
||||
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
|
||||
$loopStartedAt = (string)($row['last_loop_started_at'] ?? '');
|
||||
$loopStartedTs = strtotime($loopStartedAt);
|
||||
$loopAge = $loopStartedTs !== false ? max(0, time() - $loopStartedTs) : null;
|
||||
$loopGap = isset($row['last_loop_gap_seconds']) ? (int)$row['last_loop_gap_seconds'] : null;
|
||||
$consecutiveMinuteLoops = (int)($row['consecutive_minute_loops'] ?? 0);
|
||||
$minuteCadenceVerified = ($row['status'] ?? '') === 'running'
|
||||
&& $loopAge !== null
|
||||
&& $loopAge <= 60
|
||||
&& $loopGap !== null
|
||||
&& $loopGap <= 60
|
||||
&& $consecutiveMinuteLoops >= 2;
|
||||
|
||||
return [
|
||||
'worker_id' => (string)($row['worker_id'] ?? ''),
|
||||
@@ -255,15 +225,6 @@ class cron_worker
|
||||
'last_heartbeat_age_seconds' => $age,
|
||||
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
|
||||
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
|
||||
'last_loop_age_seconds' => $loopAge,
|
||||
'last_loop_gap_seconds' => $loopGap,
|
||||
'consecutive_minute_loops' => $consecutiveMinuteLoops,
|
||||
'minute_cadence' => [
|
||||
'verified' => $minuteCadenceVerified,
|
||||
'maximum_gap_seconds' => 60,
|
||||
'last_gap_seconds' => $loopGap,
|
||||
'consecutive_loops' => $consecutiveMinuteLoops,
|
||||
],
|
||||
'stopped_at' => $row['stopped_at'] ?? null,
|
||||
'stale' => $age === null || $age > $threshold,
|
||||
'stale_after_seconds' => $threshold,
|
||||
@@ -296,7 +257,7 @@ class cron_worker
|
||||
return $default;
|
||||
}
|
||||
|
||||
return self::normalizeBoolean($value);
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<?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,10 +14,6 @@ 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);
|
||||
|
||||
@@ -66,15 +62,9 @@ class customer_mass_import_service
|
||||
}
|
||||
|
||||
$normalized['name'] = $this->resolveCreateName($normalized);
|
||||
// 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);
|
||||
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
|
||||
|
||||
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
|
||||
$createResponse = $this->createEconomicCustomer($normalized);
|
||||
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
|
||||
|
||||
if ($createdCustomerNumber !== $customerNumber) {
|
||||
@@ -121,7 +111,6 @@ 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),
|
||||
];
|
||||
}
|
||||
@@ -204,42 +193,6 @@ 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) {
|
||||
@@ -256,9 +209,12 @@ class customer_mass_import_service
|
||||
|
||||
protected function resolveCreateEmail(array $normalized, array &$warnings): string
|
||||
{
|
||||
// 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);
|
||||
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';
|
||||
}
|
||||
|
||||
protected function searchEconomicCustomersByCvr(string $cvr): array
|
||||
@@ -273,7 +229,7 @@ class customer_mass_import_service
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
protected function createEconomicCustomer(array $normalized, string $createEmail): object
|
||||
protected function createEconomicCustomer(array $normalized): object
|
||||
{
|
||||
$payload = [
|
||||
'customerNumber' => (int)$normalized['customer_number'],
|
||||
@@ -285,10 +241,7 @@ class customer_mass_import_service
|
||||
'paymentTermsNumber' => 12,
|
||||
],
|
||||
'name' => (string)$normalized['name'],
|
||||
// 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,
|
||||
'email' => (string)$normalized['email'],
|
||||
'phone' => (int)$normalized['phone'],
|
||||
'telephoneAndFaxNumber' => (string)$normalized['phone'],
|
||||
'mobilePhone' => (string)$normalized['phone'],
|
||||
@@ -443,7 +396,6 @@ 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,
|
||||
@@ -464,7 +416,6 @@ 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() ?? ''));
|
||||
@@ -480,16 +431,6 @@ 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);
|
||||
|
||||
@@ -396,7 +396,7 @@ class customer_rule_product_restriction_service
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @return list<array{id: ?int, name: string, sort_order: int, product_ids: list<int>}> */
|
||||
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
|
||||
private function validateCollections(string $attribute, mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
|
||||
@@ -25,7 +25,6 @@ class department_customer_pricing_service
|
||||
'customer' => $customer,
|
||||
'overrides' => $overrides,
|
||||
'categories' => $this->catalog($departmentId, $customer['id']),
|
||||
'revision' => $this->revision($overrides),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -53,69 +52,34 @@ class department_customer_pricing_service
|
||||
|
||||
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
||||
$overrideObject = new department_customer_price_overrides_o();
|
||||
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
|
||||
$existingOverrides = [];
|
||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||
$normalizedKeys = [];
|
||||
foreach ($normalized as $override) {
|
||||
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$mysqli = $db->conn();
|
||||
$mysqli->begin_transaction();
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$this->lockDepartment($departmentId);
|
||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||
$currentRevision = $this->revision($existingOverrides);
|
||||
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||
throw $this->revisionConflict($currentRevision);
|
||||
}
|
||||
|
||||
$deleteStatement = $mysqli->prepare(
|
||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?'
|
||||
$db->query(
|
||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
|
||||
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
|
||||
);
|
||||
$insertStatement = $mysqli->prepare(
|
||||
'INSERT INTO `department_customer_price_overrides`
|
||||
(`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($deleteStatement === false || $insertStatement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department customer pricing update.');
|
||||
}
|
||||
|
||||
$customerId = (int)$customer['id'];
|
||||
$deleteStatement->bind_param('ii', $departmentId, $customerId);
|
||||
if (!$deleteStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to clear department customer pricing.');
|
||||
}
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$isCategory = (int)(bool)$override['is_category'];
|
||||
$objectId = (string)$override['product_or_category_id'];
|
||||
$percentage = (int)$override['percentage'];
|
||||
$fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price'];
|
||||
$insertStatement->bind_param(
|
||||
'iiisii',
|
||||
$overrideObject->setPrice(
|
||||
$departmentId,
|
||||
$customerId,
|
||||
$isCategory,
|
||||
$objectId,
|
||||
$percentage,
|
||||
$fixedPrice
|
||||
$customer['id'],
|
||||
(bool)$override['is_category'],
|
||||
$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
$override['fixed_price']
|
||||
);
|
||||
if (!$insertStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to save department customer pricing.');
|
||||
}
|
||||
}
|
||||
|
||||
$deleteStatement->close();
|
||||
$insertStatement->close();
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
$db->conn()->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
||||
}
|
||||
|
||||
@@ -290,9 +254,6 @@ class department_customer_pricing_service
|
||||
}
|
||||
|
||||
if ($isCategory) {
|
||||
if ($fixedPrice !== null) {
|
||||
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
|
||||
}
|
||||
$fixedPrice = null;
|
||||
$objectId = (string)$objectId;
|
||||
if ($objectId !== 'global') {
|
||||
@@ -308,14 +269,6 @@ class department_customer_pricing_service
|
||||
}
|
||||
|
||||
$key = $this->overrideKey($isCategory, $objectId);
|
||||
if (isset($normalized[$key])) {
|
||||
throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400);
|
||||
}
|
||||
|
||||
if ($fixedPrice !== null && $percentage > 0) {
|
||||
throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400);
|
||||
}
|
||||
|
||||
$normalized[$key] = [
|
||||
'is_category' => $isCategory,
|
||||
'product_or_category_id' => $objectId,
|
||||
@@ -360,64 +313,6 @@ class department_customer_pricing_service
|
||||
return ((int)$isCategory) . ':' . (string)$objectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $overrides
|
||||
*/
|
||||
private function revision(array $overrides): string
|
||||
{
|
||||
$revisionRows = array_map(static fn(array $override): array => [
|
||||
'is_category' => (bool)$override['is_category'],
|
||||
'product_or_category_id' => (string)$override['product_or_category_id'],
|
||||
'percentage' => (int)$override['percentage'],
|
||||
'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'],
|
||||
], $overrides);
|
||||
|
||||
usort($revisionRows, static function (array $left, array $right): int {
|
||||
return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']]
|
||||
<=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']];
|
||||
});
|
||||
|
||||
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function normalizeExpectedRevision(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
// Keep the backend-first rollout compatible with the currently deployed UI.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
|
||||
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
|
||||
'message' => 'Expected revision is invalid.',
|
||||
'code' => 'pricing_revision_invalid',
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function lockDepartment(int $departmentId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE'
|
||||
);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function revisionConflict(string $currentRevision): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
|
||||
'message' => 'Pricing has changed. Reload and try again.',
|
||||
'code' => 'pricing_revision_conflict',
|
||||
'current_revision' => $currentRevision,
|
||||
]);
|
||||
}
|
||||
|
||||
private function assertDepartmentProduct(int $departmentId, int $productId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -302,10 +302,10 @@ class department_outside_hours_statistics_service
|
||||
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
|
||||
* @param array<string,array<int,bool>>|null $missing_lookup_by_day
|
||||
* @return array{
|
||||
* counted: bool,
|
||||
* reason: string,
|
||||
* candidate_date: ?string,
|
||||
* department_id: int
|
||||
* counted:bool,
|
||||
* reason:string,
|
||||
* candidate_date:?string,
|
||||
* department_id:int
|
||||
* }
|
||||
*/
|
||||
public function classifyCandidateAgainstOpeningHours(
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Sanitizes user-input fields that are sent to the e-conomic API.
|
||||
*
|
||||
* Background: e-conomic returns 400 errors when description fields contain
|
||||
* certain characters. The known issue is "/" in the order reference field
|
||||
* (TRU-188), but we sanitize defensively for all such cases.
|
||||
*
|
||||
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
|
||||
* - sanitizeProductNumber(): for product identifiers
|
||||
* - sanitizeProductDescription(): for product-line descriptions
|
||||
* - sanitizeForEconApi(): catch-all for arbitrary user input
|
||||
*/
|
||||
class economic_export_sanitizer
|
||||
{
|
||||
/** E-conomic soft limit for a single description line. */
|
||||
public const TEXT_LINE_MAX_LENGTH = 250;
|
||||
/** E-conomic soft limit for a product description. */
|
||||
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
|
||||
/** E-conomic soft limit for a product number. */
|
||||
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
|
||||
|
||||
/** Characters that are illegal in product numbers on most e-conomic setups. */
|
||||
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
|
||||
|
||||
/**
|
||||
* Sanitize a value for use in a single-line text description.
|
||||
*
|
||||
* Transformations (in order):
|
||||
* 1. Replaces "/" with "-" (the reported 400 trigger)
|
||||
* 2. Strips control characters (\x00-\x1F) except \t and \n
|
||||
* 3. Replaces tab with single space
|
||||
* 4. Collapses newlines into spaces (text lines are single-line)
|
||||
* 5. Collapses runs of spaces to a single space
|
||||
* 6. Trims leading/trailing whitespace
|
||||
* 7. Truncates to $maxLength with "..." suffix if needed
|
||||
*/
|
||||
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
// 1. Strip control characters except \t and \n
|
||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
|
||||
// 2. Replace tab with single space
|
||||
$text = str_replace("\t", ' ', $text);
|
||||
// 3. Collapse newlines to single space (text lines are single-line)
|
||||
$text = preg_replace('/[\r\n]+/u', ' ', $text);
|
||||
// 4. Replace forward slashes (the reported 400 trigger)
|
||||
$text = str_replace('/', '-', $text);
|
||||
// 5. Collapse runs of spaces
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
// 6. Trim
|
||||
$text = trim($text);
|
||||
// 7. Truncate with ellipsis if too long
|
||||
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength - 3) . '...';
|
||||
} elseif (mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a product number/identifier.
|
||||
*
|
||||
* Removes characters that are illegal in product numbers on most
|
||||
* e-conomic setups (filesystem-unsafe + path separators).
|
||||
*/
|
||||
public static function sanitizeProductNumber(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
|
||||
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
|
||||
$text = trim($text);
|
||||
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
|
||||
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a longer product description.
|
||||
*/
|
||||
public static function sanitizeProductDescription(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all sanitizer for any user-input value going to e-conomic.
|
||||
* Defaults to text-line rules.
|
||||
*/
|
||||
public static function sanitizeForEconApi(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value);
|
||||
}
|
||||
}
|
||||
@@ -328,21 +328,23 @@ class economic_transfer_executor
|
||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||
$order_item_price = (float)($order_item['price'] ?? 0);
|
||||
$product_price = (float)($order_item['product']['price'] ?? 0);
|
||||
$discount_percentage = 0.0;
|
||||
if (abs($product_price) > 0.00001 && $order_item_price < $product_price) {
|
||||
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 10);
|
||||
}
|
||||
|
||||
$economic_invoice_draft->addLine(
|
||||
$product_number,
|
||||
$product_name,
|
||||
$quantity,
|
||||
$order_item_price,
|
||||
$discount_percentage,
|
||||
0,
|
||||
(int)$economic_department_id ?? 0,
|
||||
(int)$economic_dimension_id ?? 0
|
||||
);
|
||||
|
||||
$show_discount = abs($order_item_price - $product_price) > 0.00001;
|
||||
if ($show_discount && abs($product_price) > 0.00001) {
|
||||
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0);
|
||||
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)');
|
||||
}
|
||||
|
||||
if ($reference !== '') {
|
||||
$economic_invoice_draft->addLineTEXT('Reference:');
|
||||
if (str_contains($reference, "\n")) {
|
||||
|
||||
@@ -40,14 +40,9 @@ class economic_transfer_queue
|
||||
$max_attempts = max(1, min(10, $max_attempts));
|
||||
$transfer_type = $this->validateTransferType($transfer_type);
|
||||
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
||||
$collected_invoice_lock = $this->acquireCollectedInvoiceExportLock($transfer_type, $payload);
|
||||
if ($collected_invoice_lock !== null) {
|
||||
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||
}
|
||||
|
||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
|
||||
if ($active_job !== null) {
|
||||
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
|
||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
@@ -80,8 +75,6 @@ class economic_transfer_queue
|
||||
$job_id = (int)$db->insert_id();
|
||||
$stmt->close();
|
||||
|
||||
$this->registerJobRequester($job_id, $created_by);
|
||||
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
$created_by,
|
||||
@@ -152,19 +145,12 @@ class economic_transfer_queue
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"SELECT q.*
|
||||
FROM economic_transfer_queue_jobs q
|
||||
LEFT JOIN economic_transfer_queue_job_requesters r
|
||||
ON r.queue_job_id = q.id AND r.user_id = ?
|
||||
WHERE q.id = ? AND (q.created_by = ? OR r.user_id = ?)
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt->bind_param('iiii', $created_by, $job_id, $created_by, $created_by);
|
||||
$stmt->bind_param('ii', $job_id, $created_by);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
@@ -193,8 +179,7 @@ class economic_transfer_queue
|
||||
$offset = max(0, $offset);
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
|
||||
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -218,8 +203,7 @@ class economic_transfer_queue
|
||||
}
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
|
||||
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -258,9 +242,6 @@ class economic_transfer_queue
|
||||
global $db;
|
||||
|
||||
$user_id = max(0, $user_id);
|
||||
if ($user_id < 1) {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(100, $limit));
|
||||
try {
|
||||
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||
@@ -281,7 +262,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
|
||||
WHERE q.created_by = $user_id
|
||||
$transfer_condition
|
||||
AND (
|
||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||
@@ -374,7 +355,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
|
||||
WHERE q.created_by = $user_id
|
||||
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||
$transfer_condition
|
||||
AND d.queue_job_id IS NULL
|
||||
@@ -408,9 +389,6 @@ class economic_transfer_queue
|
||||
if ($existing_job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
if ($created_by !== null && (int)($existing_job['created_by'] ?? 0) !== $created_by) {
|
||||
throw new Exception('Only the queue job creator can retry this job');
|
||||
}
|
||||
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
|
||||
throw new Exception('Only failed jobs can be retried');
|
||||
}
|
||||
@@ -632,53 +610,11 @@ class economic_transfer_queue
|
||||
if ($collected_invoice_id < 1) {
|
||||
throw new Exception('collected_invoice_id is required');
|
||||
}
|
||||
$collection_lock = $this->acquireCollectedInvoiceExportLock(
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||
$payload
|
||||
);
|
||||
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||
$send_as_is = (bool)($payload['send_as_is'] ?? false);
|
||||
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
|
||||
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue, worker execution, payments, and invoice-tree mutations share the
|
||||
* same collection lock. The returned object intentionally stays in scope
|
||||
* for the complete enqueue/export operation and releases in its destructor.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function acquireCollectedInvoiceExportLock(string $transfer_type, array $payload): ?order_payment_lock
|
||||
{
|
||||
if ($transfer_type !== self::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||
return null;
|
||||
}
|
||||
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
|
||||
if ($collected_invoice_id < 1) {
|
||||
throw new Exception('collected_invoice_id is required');
|
||||
}
|
||||
$lock = order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id);
|
||||
if ($lock === null) {
|
||||
throw new Exception('Invoice collection is currently being changed or paid. Try again.');
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read eligibility after acquiring the collection lock so a queued job
|
||||
* cannot export a collection that was booked or superseded while waiting.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertCollectedInvoiceExportIsStillEligible(array $payload): void
|
||||
{
|
||||
$collection = (new \objects\collected_order_invoices_o())->select(
|
||||
(int)($payload['collected_invoice_id'] ?? 0)
|
||||
);
|
||||
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection);
|
||||
}
|
||||
|
||||
private function updateProgress(int $job_id, int $percent, string $message): void
|
||||
{
|
||||
global $db;
|
||||
@@ -791,38 +727,6 @@ class economic_transfer_queue
|
||||
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
|
||||
}
|
||||
|
||||
private function registerJobRequester(int $job_id, int $user_id): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($job_id < 1 || $user_id < 1) {
|
||||
return;
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO economic_transfer_queue_job_requesters (queue_job_id, user_id, requested_at)
|
||||
VALUES (?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE requested_at = VALUES(requested_at)"
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Failed to prepare queue requester registration');
|
||||
}
|
||||
$stmt->bind_param('ii', $job_id, $user_id);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to register queue requester');
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
private function jobVisibilitySql(string $alias, int $user_id): string
|
||||
{
|
||||
$user_id = max(0, $user_id);
|
||||
return "($alias.created_by = $user_id OR EXISTS (
|
||||
SELECT 1 FROM economic_transfer_queue_job_requesters requester
|
||||
WHERE requester.queue_job_id = $alias.id AND requester.user_id = $user_id
|
||||
))";
|
||||
}
|
||||
|
||||
/**
|
||||
* Release jobs stuck in PROCESSING due to crashes or killed workers.
|
||||
*/
|
||||
@@ -852,14 +756,11 @@ class economic_transfer_queue
|
||||
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
|
||||
}
|
||||
|
||||
if ($transfer_type === self::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||
return $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by);
|
||||
}
|
||||
if (!in_array($transfer_type, [self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT], true)) {
|
||||
$this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type);
|
||||
}
|
||||
|
||||
return $this->normalizeOrderPayload($normalized_payload, $created_by);
|
||||
return match ($transfer_type) {
|
||||
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
|
||||
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,7 +770,7 @@ class economic_transfer_queue
|
||||
{
|
||||
$order_id = $payload['order_id'] ?? null;
|
||||
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
|
||||
$this->rejectPayload($created_by, 'order_id is required and must be a positive number');
|
||||
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
|
||||
}
|
||||
$payload['order_id'] = (int)$order_id;
|
||||
return $payload;
|
||||
@@ -882,7 +783,7 @@ class economic_transfer_queue
|
||||
{
|
||||
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
|
||||
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
|
||||
$this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
|
||||
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
|
||||
}
|
||||
|
||||
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
|
||||
@@ -903,17 +804,17 @@ class economic_transfer_queue
|
||||
if ($numeric === 0 || $numeric === 1) {
|
||||
return $numeric === 1;
|
||||
}
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
|
||||
return in_array($normalized, ['true', '1'], true);
|
||||
}
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
|
||||
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
|
||||
@@ -939,8 +840,8 @@ class economic_transfer_queue
|
||||
{
|
||||
global $db;
|
||||
|
||||
// Active work is unique by transfer type and business target across all requesting users.
|
||||
if ($target_value < 1) {
|
||||
$created_by = max(0, $created_by);
|
||||
if ($target_value < 1 || $created_by < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -950,6 +851,7 @@ class economic_transfer_queue
|
||||
WHERE transfer_type = ?
|
||||
AND status IN (?, ?)
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||
AND created_by = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -959,7 +861,7 @@ class economic_transfer_queue
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$processing = self::STATUS_PROCESSING;
|
||||
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
|
||||
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
|
||||
@@ -54,17 +54,6 @@ class economic_transfer_queue_schema_bootstrap
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters (
|
||||
queue_job_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (queue_job_id, user_id),
|
||||
INDEX idx_economic_transfer_queue_job_requesters_user (user_id, queue_job_id),
|
||||
INDEX idx_economic_transfer_queue_job_requesters_job (queue_job_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class economic_v2_revenue_statistics_service
|
||||
|
||||
private economic $economic;
|
||||
|
||||
/** @var array<int, array{customer_number: int, name: ?string, barred: ?bool, status: string}> */
|
||||
/** @var array<int, array{customer_number:int,name:?string,barred:?bool,status:string}> */
|
||||
private array $customer_cache = [];
|
||||
|
||||
public function __construct(?economic $economic = null)
|
||||
@@ -44,6 +44,7 @@ class economic_v2_revenue_statistics_service
|
||||
$summary = [
|
||||
'invoice_count' => 0,
|
||||
'line_count' => 0,
|
||||
'unique_customers' => 0,
|
||||
'net_amount' => 0.0,
|
||||
'vat_amount' => 0.0,
|
||||
'gross_amount' => 0.0,
|
||||
@@ -397,7 +398,7 @@ class economic_v2_revenue_statistics_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{customer_number: int, name: ?string, barred: ?bool, status: string}
|
||||
* @return array{customer_number:int,name:?string,barred:?bool,status:string}
|
||||
*/
|
||||
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
|
||||
{
|
||||
@@ -499,3 +500,4 @@ class economic_v2_revenue_statistics_service
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ use MailerSend\Helpers\Builder\Recipient;
|
||||
use MailerSend\MailerSend;
|
||||
use objects\bookings_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
|
||||
@@ -145,15 +144,6 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
];
|
||||
// If the email is blacklisted, return without sending the email
|
||||
if (in_array($to, $blacklisted_emails)) {
|
||||
// Previously this was a silent return - ops could not tell whether a
|
||||
// missing delivery was caused by the blacklist or a real provider
|
||||
// outage. Emit a structured skip event before returning.
|
||||
$context = [
|
||||
'reason' => 'recipient_blacklisted',
|
||||
'recipient' => $to,
|
||||
'subject' => $subject,
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
return;
|
||||
}
|
||||
// Send POST request to email service
|
||||
@@ -174,19 +164,12 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
if ($attachments) {
|
||||
$attachments = array_map(function ($attachment) {
|
||||
// Read the data from the path (Attachment[0]) and set the filename (Attachment[1])
|
||||
$path = (string)$attachment[0];
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
// Capture the path before it is overwritten so the resulting
|
||||
// exception message is useful in ops logs. Previously this
|
||||
// threw with binary contents (because $attachment[0] had
|
||||
// already been replaced by file_get_contents()'s output),
|
||||
// making the failure essentially un-diagnosable.
|
||||
throw new Exception('Failed to read attachment file: ' . $path);
|
||||
$attachment[0] = file_get_contents($attachment[0]);
|
||||
if ($attachment[0] === false) {
|
||||
throw new Exception('Failed to read file: ' . $attachment[0]);
|
||||
}
|
||||
$attachment[0] = $contents;
|
||||
if (empty($attachment[1])) {
|
||||
throw new Exception('Attachment filename is empty (path: ' . $path . ')');
|
||||
throw new Exception('Filename is empty');
|
||||
}
|
||||
return new Attachment($attachment[0], $attachment[1]);
|
||||
}, $attachments);
|
||||
@@ -506,13 +489,7 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
{
|
||||
// Validate the booking object
|
||||
$order_booking->requireSelected();
|
||||
if (!$order_booking->hasTransaction()) {
|
||||
// Previously this was a silent return which made wash-certificate
|
||||
// delivery failures (e.g. k.sand@ksand.dk) impossible to diagnose
|
||||
// without DB access. Emit a structured skip event before returning.
|
||||
self::logWashCertificateSkip('no_transaction_email', (int)$order_booking->id, (int)$order_booking->customer_number->value());
|
||||
return;
|
||||
} // Only send a wash certificate if the order has been created.
|
||||
if (!$order_booking->hasTransaction()) return; // Only send a wash certificate if the order has been created.
|
||||
// Get the order details
|
||||
$order = $order_booking->getOrder();
|
||||
// Get customer details
|
||||
@@ -627,14 +604,6 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
||||
if ($recipientEmail === '') {
|
||||
// Recipient has no email address; surface the skip so an admin
|
||||
// with no configured inbox can be fixed instead of silently
|
||||
// dropping new-customer notifications.
|
||||
$context = [
|
||||
'reason' => 'superuser_recipient_empty_email',
|
||||
'recipient_display_name' => trim((string)($recipient['display_name'] ?? '')),
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -643,61 +612,12 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$recipientName = $recipientEmail;
|
||||
}
|
||||
|
||||
// Per-recipient try/catch so a single bad MailerSend response does
|
||||
// not break delivery to the remaining superuser recipients - this
|
||||
// loop is unprotected upstream and a transient 5xx would otherwise
|
||||
// mean the rest of the team silently stops hearing about new
|
||||
// customer registrations.
|
||||
try {
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$context = [
|
||||
'reason' => 'superuser_recipient_send_failed',
|
||||
'recipient' => $recipientEmail,
|
||||
'subject' => 'New customer registered on Truck Wash',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a structured "wash certificate was skipped" event.
|
||||
*
|
||||
* Mirrors the helper of the same name on order_bookings_o so that every
|
||||
* silent-return path in the email delivery flow (this class plus the
|
||||
* order-bookings wrapper) is observable from the same grep target.
|
||||
*
|
||||
* TODO: migrate to the project logger when one is available globally.
|
||||
*
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private static function logWashCertificateSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
|
||||
{
|
||||
$context = array_merge([
|
||||
'reason' => $reason,
|
||||
'booking_id' => $booking_id,
|
||||
'customer_number' => $customer_number,
|
||||
], $extra);
|
||||
try {
|
||||
(new logs_o())->add(
|
||||
'email',
|
||||
'global',
|
||||
3,
|
||||
0,
|
||||
'WASH_CERT_SKIP',
|
||||
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Logging must never block the booking flow.
|
||||
}
|
||||
// Also emit to PHP error stream so this is visible in container logs.
|
||||
error_log('[wash-cert-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,41 @@ class encrypt implements encrypt_i
|
||||
|
||||
public function encrypt(string $data): string
|
||||
{
|
||||
// Debug:
|
||||
return $data;
|
||||
// Encrypt data
|
||||
global $ENCRYPTION_KEY;
|
||||
// Use AES 256 encryption
|
||||
$cipher = "aes-256-cbc";
|
||||
// Use the encryption key
|
||||
$options = 0;
|
||||
// Get the initialization vector
|
||||
$iv_length = openssl_cipher_iv_length($cipher);
|
||||
$iv = openssl_random_pseudo_bytes($iv_length);
|
||||
// Use the first 16 bytes of the initialization vector
|
||||
$iv = substr($iv, 0, 16);
|
||||
// Encrypt the data
|
||||
$encrypted = openssl_encrypt($data, $cipher, $ENCRYPTION_KEY, $options, $iv);
|
||||
// Save the initialization vector for decryption
|
||||
return $iv . $encrypted;
|
||||
}
|
||||
|
||||
public function decrypt(string $data): string
|
||||
{
|
||||
// Debug:
|
||||
return $data;
|
||||
// Decrypt data
|
||||
global $ENCRYPTION_KEY;
|
||||
// Use AES 256 encryption
|
||||
$cipher = "aes-256-cbc";
|
||||
// Use the encryption key and initialization vector
|
||||
$options = 0;
|
||||
// Get the initialization vector
|
||||
$iv_length = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($data, 0, $iv_length);
|
||||
// Get the encrypted data
|
||||
$encrypted = substr($data, $iv_length);
|
||||
// Decrypt the data
|
||||
return openssl_decrypt($encrypted, $cipher, $ENCRYPTION_KEY, $options, $iv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ namespace classes;
|
||||
require_once WD . '/modules/entra/entra_c.php';
|
||||
|
||||
use entra\entra_c;
|
||||
use Microsoft\Graph\GraphServiceClient;
|
||||
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
|
||||
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContextBuilder;
|
||||
|
||||
|
||||
class entra
|
||||
@@ -20,95 +23,42 @@ class entra
|
||||
$this->config = new entra_c();
|
||||
}
|
||||
|
||||
public function get_users(bool $array = false): array
|
||||
public function get_users($array = false): array|object
|
||||
{
|
||||
$accessToken = $this->requestAccessToken();
|
||||
$usersResponse = $this->requestJson(
|
||||
'https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,userPrincipalName',
|
||||
['Authorization: Bearer ' . $accessToken]
|
||||
);
|
||||
$users = is_array($usersResponse['value'] ?? null) ? $usersResponse['value'] : [];
|
||||
$graphClient = $this->getGraphClient();
|
||||
|
||||
$users = $graphClient->users()
|
||||
->get()
|
||||
->wait()
|
||||
->getValue();
|
||||
if (!$array) {
|
||||
return $users;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($users as $user) {
|
||||
if (!is_array($user)) {
|
||||
continue;
|
||||
}
|
||||
foreach ( $users as $user ) {
|
||||
$result[] = [
|
||||
'id' => $user['id'] ?? null,
|
||||
'displayName' => $user['displayName'] ?? null,
|
||||
'mail' => $user['mail'] ?? null,
|
||||
'userPrincipalName' => $user['userPrincipalName'] ?? null,
|
||||
'id' => $user->getId(),
|
||||
'displayName' => $user->getDisplayName(),
|
||||
'mail' => $user->getMail(),
|
||||
'userPrincipalName' => $user->getUserPrincipalName(),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function requestAccessToken(): string
|
||||
public function getGraphClient(): GraphServiceClient
|
||||
{
|
||||
$tenantId = trim((string)$this->config->tenant_id->getVariableValue());
|
||||
$response = $this->requestJson(
|
||||
'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/oauth2/v2.0/token',
|
||||
['Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query([
|
||||
'client_id' => (string)$this->config->client_id->getVariableValue(),
|
||||
'client_secret' => (string)$this->config->client_secret->getVariableValue(),
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'grant_type' => 'client_credentials',
|
||||
])
|
||||
return new GraphServiceClient(
|
||||
$this->getTokenRequestContext(),
|
||||
);
|
||||
|
||||
$token = trim((string)($response['access_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('Microsoft Entra token response did not contain an access token.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function requestJson(string $url, array $headers, ?string $postFields = null): array
|
||||
public function getTokenRequestContext(): ClientCredentialContext
|
||||
{
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new \RuntimeException('Unable to initialize Microsoft Entra request.');
|
||||
}
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
if ($postFields !== null) {
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $postFields);
|
||||
}
|
||||
|
||||
try {
|
||||
$body = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
if ($body === false) {
|
||||
throw new \RuntimeException('Microsoft Entra request failed: ' . curl_error($curl));
|
||||
}
|
||||
} finally {
|
||||
curl_close($curl);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$body, true);
|
||||
if ($status < 200 || $status >= 300 || !is_array($decoded)) {
|
||||
$message = is_array($decoded)
|
||||
? (string)($decoded['error_description'] ?? $decoded['error']['message'] ?? 'Unexpected response')
|
||||
: 'Invalid JSON response';
|
||||
throw new \RuntimeException('Microsoft Entra request failed with HTTP ' . $status . ': ' . $message);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
return new ClientCredentialContext(
|
||||
$this->config->tenant_id->getVariableValue(),
|
||||
$this->config->client_id->getVariableValue(),
|
||||
$this->config->client_secret->getVariableValue()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ require_once WD . '/modules/forms/form_helper_c.php';
|
||||
|
||||
use Exception;
|
||||
use forms\form_helper_c;
|
||||
use forms\objects\book_interior_wash_f;
|
||||
use forms\objects\book_wash_f;
|
||||
use objects\form_submissions_o;
|
||||
use traits\form_t;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive audit columns used when invoice collections are superseded.
|
||||
*/
|
||||
class invoice_collection_schema_bootstrap
|
||||
{
|
||||
private const TABLE = 'collected_order_invoices';
|
||||
private const LOCK_NAME = 'invoice_collection_schema_v1';
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const REQUIRED_COLUMNS = [
|
||||
'superseded_by_collection_id' => 'INT NULL',
|
||||
'superseded_at' => 'DATETIME NULL',
|
||||
'superseded_by_user_id' => 'INT NULL',
|
||||
];
|
||||
|
||||
public static function hasRequiredColumns(): bool
|
||||
{
|
||||
try {
|
||||
global $db;
|
||||
if (!self::canInspectSchema($db) || !self::tableExists($db)) {
|
||||
return false;
|
||||
}
|
||||
if (self::allColumnsExist($db)) {
|
||||
return true;
|
||||
}
|
||||
if (!self::acquireLock($db)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Another worker may have completed the additive migration while
|
||||
// this request waited for the advisory lock.
|
||||
foreach (self::REQUIRED_COLUMNS as $column => $definition) {
|
||||
if (self::columnExists($db, $column)) {
|
||||
continue;
|
||||
}
|
||||
$result = $db->query(
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}"
|
||||
);
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!self::allColumnsExist($db)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
self::releaseLock($db);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Schema readiness is a capability gate. It must never break the
|
||||
// legacy invoicing routes when DDL or metadata access is unavailable.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function canInspectSchema(mixed $db): bool
|
||||
{
|
||||
return is_object($db)
|
||||
&& method_exists($db, 'query')
|
||||
&& method_exists($db, 'escape_string')
|
||||
&& method_exists($db, 'getDatabase');
|
||||
}
|
||||
|
||||
private static function tableExists(object $db): bool
|
||||
{
|
||||
return self::informationSchemaCount(
|
||||
$db,
|
||||
'information_schema.TABLES',
|
||||
'TABLE_NAME',
|
||||
self::TABLE
|
||||
) > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $column): bool
|
||||
{
|
||||
return self::informationSchemaCount(
|
||||
$db,
|
||||
'information_schema.COLUMNS',
|
||||
'COLUMN_NAME',
|
||||
$column
|
||||
) > 0;
|
||||
}
|
||||
|
||||
private static function informationSchemaCount(
|
||||
object $db,
|
||||
string $informationSchemaTable,
|
||||
string $nameField,
|
||||
string $name
|
||||
): int {
|
||||
$database = $db->escape_string((string)$db->getDatabase());
|
||||
$name = $db->escape_string($name);
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS c FROM {$informationSchemaTable} "
|
||||
. "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' "
|
||||
. "AND {$nameField} = '{$name}'"
|
||||
);
|
||||
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
|
||||
throw new \RuntimeException('Invoice collection schema inspection failed');
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
if (!is_array($row) || !array_key_exists('c', $row)) {
|
||||
throw new \RuntimeException('Invoice collection schema inspection returned an invalid result');
|
||||
}
|
||||
return (int)$row['c'];
|
||||
}
|
||||
|
||||
private static function allColumnsExist(object $db): bool
|
||||
{
|
||||
foreach (array_keys(self::REQUIRED_COLUMNS) as $column) {
|
||||
if (!self::columnExists($db, $column)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function acquireLock(object $db): bool
|
||||
{
|
||||
$result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired");
|
||||
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
|
||||
return false;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) && (int)($row['acquired'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
private static function releaseLock(object $db): void
|
||||
{
|
||||
try {
|
||||
$db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')");
|
||||
} catch (\Throwable) {
|
||||
// The connection also releases advisory locks automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,7 @@ class invoice_period_flag_schema_bootstrap
|
||||
);
|
||||
|
||||
products_schema_bootstrap::ensureTables();
|
||||
|
||||
// Invoice-period flags remain available while the separately operated
|
||||
// XL Vask automation migration is pending. XL Vask-specific flag
|
||||
// detection already fails closed when its optional schema is absent.
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -76,11 +76,8 @@ class invoice_period_flag_service
|
||||
$this->nullableIntSql($userId > 0 ? $userId : null)
|
||||
);
|
||||
$db->query($sql);
|
||||
$flagId = (int)$db->insert_id();
|
||||
|
||||
$this->refreshManualFlagsCacheAfterMutation();
|
||||
|
||||
return $this->getStoredFlag($flagId);
|
||||
return $this->getStoredFlag((int)$db->insert_id());
|
||||
}
|
||||
|
||||
public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array
|
||||
@@ -110,8 +107,6 @@ class invoice_period_flag_service
|
||||
);
|
||||
$db->query($sql);
|
||||
|
||||
$this->refreshManualFlagsCacheAfterMutation();
|
||||
|
||||
return $this->getStoredFlag($id);
|
||||
}
|
||||
|
||||
@@ -232,61 +227,6 @@ class invoice_period_flag_service
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add only aggregate active manual-flag counts for readiness derivation.
|
||||
* Flag details remain absent when the caller lacks list_invoice_period_flags.
|
||||
*
|
||||
* @param array<string,array<int,array<string,mixed>>> $types
|
||||
* @param int[]|null $onlyCustomerNumbers
|
||||
* @return array<string,array<int,array<string,mixed>>>
|
||||
*/
|
||||
public function applyManualFlagCountsToPeriodTypes(
|
||||
array $types,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
?array $onlyCustomerNumbers = null
|
||||
): array {
|
||||
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
return $this->applyManualFlagCounts($types, $manualFlags);
|
||||
}
|
||||
|
||||
private function applyManualFlagCounts(array $types, array $manualFlags): array
|
||||
{
|
||||
$flagsByCustomerNumber = [];
|
||||
foreach ($manualFlags as $flag) {
|
||||
$customerNumber = (int)($flag['customer_number'] ?? 0);
|
||||
if ($customerNumber > 0) {
|
||||
$flagsByCustomerNumber[$customerNumber][] = $flag;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($types as $typeName => $customers) {
|
||||
foreach ($customers as $index => $customer) {
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$customerFlags = $this->flagsForCustomerCard(
|
||||
$customer,
|
||||
$flagsByCustomerNumber[$customerNumber] ?? [],
|
||||
(string)$typeName
|
||||
);
|
||||
$activeManualCount = count(array_filter(
|
||||
$customerFlags,
|
||||
static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL
|
||||
&& ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE
|
||||
));
|
||||
$existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0);
|
||||
$types[$typeName][$index]['flag_counts'] = [
|
||||
'manual' => $activeManualCount,
|
||||
'automatic' => $existingAutomaticCount,
|
||||
'total' => $activeManualCount + $existingAutomaticCount,
|
||||
];
|
||||
unset($types[$typeName][$index]['flags']);
|
||||
}
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
|
||||
{
|
||||
$transactionIds = [];
|
||||
@@ -391,12 +331,6 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshManualFlagsCacheAfterMutation(): void
|
||||
{
|
||||
$this->manualFlagsInstanceCache = null;
|
||||
$this->warmManualFlagsCache();
|
||||
}
|
||||
|
||||
private function fetchActiveManualFlagsFromDb(): array
|
||||
{
|
||||
global $db;
|
||||
@@ -1297,34 +1231,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
|
||||
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
|
||||
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
|
||||
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
|
||||
$hasReg2 = [];
|
||||
$emptyReg2 = [];
|
||||
foreach ($primaryRows as $row) {
|
||||
if (trim((string)($row['reg_2'] ?? '')) === '') {
|
||||
$emptyReg2[] = $row;
|
||||
} else {
|
||||
$hasReg2[] = $row;
|
||||
}
|
||||
}
|
||||
$history = [];
|
||||
if (!empty($emptyReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($emptyReg2, 'reg_1'),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (!empty($hasReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($hasReg2, 'reg_1'),
|
||||
false
|
||||
);
|
||||
}
|
||||
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
|
||||
foreach ($primaryRows as $row) {
|
||||
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
|
||||
if ($reg === '' || !isset($history[$reg])) {
|
||||
@@ -1495,7 +1402,6 @@ class invoice_period_flag_service
|
||||
{
|
||||
$product = (string)($params['product'] ?? 'Item');
|
||||
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
|
||||
$washId = (string)($params['wash_id'] ?? '');
|
||||
return match ($definitionKey) {
|
||||
'price_mismatch' => "{$product} product price differs from expected.",
|
||||
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
|
||||
@@ -1515,9 +1421,7 @@ class invoice_period_flag_service
|
||||
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
|
||||
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
|
||||
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
|
||||
'xlvask_missing_order_link' => $washId === ''
|
||||
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
|
||||
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
|
||||
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||
default => "Automatically detected invoice-period issue.",
|
||||
};
|
||||
}
|
||||
@@ -1544,8 +1448,7 @@ class invoice_period_flag_service
|
||||
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
|
||||
],
|
||||
'xlvask_missing_order_link' => [
|
||||
['type' => 'text', 'text' => 'XL Vask wash '],
|
||||
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
],
|
||||
default => [],
|
||||
@@ -1805,7 +1708,7 @@ class invoice_period_flag_service
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -1826,16 +1729,6 @@ class invoice_period_flag_service
|
||||
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
|
||||
return "'" . $db->escape_string($registrationNumber) . "'";
|
||||
}, array_keys($registrations)));
|
||||
// Restrict historical orders to those whose reg_2 status matches the current rows:
|
||||
// - null → no filter (default behaviour, backwards compatible)
|
||||
// - true → reg_2 empty (single-tractor orders only)
|
||||
// - false → reg_2 non-empty (tractor-trailer combo orders only)
|
||||
$reg2Filter = '';
|
||||
if ($requireReg2Empty === true) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
|
||||
} elseif ($requireReg2Empty === false) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
|
||||
}
|
||||
$result = $db->query(
|
||||
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
|
||||
FROM orders o
|
||||
@@ -1849,7 +1742,6 @@ class invoice_period_flag_service
|
||||
AND COALESCE(oi.related_item_id, 0) = 0
|
||||
AND COALESCE(o.reg_1, '') <> ''
|
||||
AND o.reg_1 IN ({$registrationFilter})
|
||||
{$reg2Filter}
|
||||
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
|
||||
ORDER BY reg, usage_count DESC, oi.product_id ASC"
|
||||
);
|
||||
|
||||
@@ -137,7 +137,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $this->completeRecognition($started_at, $cached_result);
|
||||
return $cached_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $this->completeRecognition($started_at, $recognized_result);
|
||||
return $recognized_result;
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
@@ -211,19 +211,12 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $this->completeRecognition($started_at, $recognized_result);
|
||||
} catch (\Throwable $exception) {
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function completeRecognition(float $started_at, array $result): array
|
||||
{
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
|
||||
/**
|
||||
* Issues narrowly scoped bearer grants which can be exchanged once for a normal
|
||||
* employee session. Only a SHA-256 digest is persisted. The bearer is derived
|
||||
* under the server encryption key so the same authorized idempotent request can
|
||||
* recover an unconsumed grant after a lost response without storing plaintext.
|
||||
*/
|
||||
class limited_backoffice_login_grant_service
|
||||
{
|
||||
public const PURPOSE_EMPLOYEE_DIRECT_LOGIN = 'limited_backoffice_employee_login';
|
||||
public const DEFAULT_TTL_SECONDS = 300;
|
||||
public const MIN_TTL_SECONDS = 60;
|
||||
public const MAX_TTL_SECONDS = 900;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
limited_backoffice_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function create(users_o $manager, int $employeeId, array $payload): array
|
||||
{
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
||||
|
||||
$purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN));
|
||||
if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) {
|
||||
throw new limited_backoffice_exception('Unsupported login grant purpose.', 400);
|
||||
}
|
||||
|
||||
$ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS);
|
||||
$expiresAt = time() + $ttlSeconds;
|
||||
$preflight = ($payload['preflight'] ?? false) === true;
|
||||
|
||||
$base = [
|
||||
'employee_id' => $employeeId,
|
||||
'purpose' => $purpose,
|
||||
'ttl_seconds' => $ttlSeconds,
|
||||
'expires_at' => gmdate('c', $expiresAt),
|
||||
'one_time' => true,
|
||||
];
|
||||
if ($preflight) {
|
||||
return $base + ['preflight' => true];
|
||||
}
|
||||
|
||||
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
|
||||
if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) {
|
||||
throw new limited_backoffice_exception(
|
||||
'idempotency_key must contain between 16 and 128 characters.',
|
||||
400
|
||||
);
|
||||
}
|
||||
$idempotencyKeyHash = hash('sha256', $idempotencyKey);
|
||||
|
||||
$grantId = bin2hex(random_bytes(16));
|
||||
$bearer = $this->bearerForIdempotency(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$ttlSeconds,
|
||||
$idempotencyKey
|
||||
);
|
||||
$secretHash = hash('sha256', $bearer);
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
for ($attempt = 0; $attempt < 3; $attempt++) {
|
||||
$mysqli->begin_transaction();
|
||||
try {
|
||||
if (!$this->lockActiveManagedEmployee($employeeId)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Cannot create a login grant for an inactive employee.',
|
||||
409
|
||||
);
|
||||
}
|
||||
// Target-first ordering matches employee update/deletion. Two
|
||||
// cross-managing actors can still form a cycle, so deadlock
|
||||
// victims are retried below with the same idempotency identity.
|
||||
$authorizedActor = $this->lockAuthorizedActor($manager);
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget(
|
||||
$authorizedActor['manager'],
|
||||
$employeeId,
|
||||
$authorizedActor['group_id']
|
||||
);
|
||||
|
||||
$existing = $this->findIdempotentGrant(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
if ($existing !== null) {
|
||||
if ($this->isReplayableGrant($existing, $secretHash)) {
|
||||
$mysqli->commit();
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
||||
}
|
||||
throw $this->duplicateGrantException($existing);
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `limited_backoffice_login_grants`
|
||||
(`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`,
|
||||
`idempotency_key_hash`, `expires_at`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to prepare login grant.', 500);
|
||||
}
|
||||
$actorUserId = (int)$manager->id;
|
||||
$statement->bind_param(
|
||||
'ssiissi',
|
||||
$grantId,
|
||||
$secretHash,
|
||||
$employeeId,
|
||||
$actorUserId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash,
|
||||
$expiresAt
|
||||
);
|
||||
try {
|
||||
$statement->execute();
|
||||
} finally {
|
||||
$statement->close();
|
||||
}
|
||||
$mysqli->commit();
|
||||
break;
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\mysqli_sql_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
$errorCode = (int)$exception->getCode();
|
||||
if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) {
|
||||
usleep(1000 * ($attempt + 1));
|
||||
continue;
|
||||
}
|
||||
if ($errorCode === 1062) {
|
||||
$existing = $this->findIdempotentGrant(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
if ($existing !== null) {
|
||||
if ($this->isReplayableGrant($existing, $secretHash)) {
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
||||
}
|
||||
throw $this->duplicateGrantException($existing);
|
||||
}
|
||||
}
|
||||
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
$this->audit(
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED',
|
||||
'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, [
|
||||
'grant_id' => $grantId,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,token:string}
|
||||
*/
|
||||
public function exchange(string $bearer): array
|
||||
{
|
||||
if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$secretHash = hash('sha256', $bearer);
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
// Resolve the target without locking, then lock employee -> grant. Employee
|
||||
// deactivation uses the same order, preventing a direct-login session from
|
||||
// surviving a concurrent deactivation and avoiding inverse-order deadlocks.
|
||||
$targetLookup = $mysqli->prepare(
|
||||
'SELECT `target_user_id`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `secret_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($targetLookup === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant target lookup.');
|
||||
}
|
||||
$targetLookup->bind_param('s', $secretHash);
|
||||
$targetLookup->execute();
|
||||
$target = $targetLookup->get_result()->fetch_assoc() ?: null;
|
||||
$targetLookup->close();
|
||||
if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`,
|
||||
`consumed_at`, `revoked_at`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `secret_hash` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant exchange.');
|
||||
}
|
||||
$statement->bind_param('s', $secretHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
|
||||
if (
|
||||
$row === null
|
||||
|| $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN
|
||||
|| $row['consumed_at'] !== null
|
||||
|| $row['revoked_at'] !== null
|
||||
|| (int)$row['expires_at'] <= time()
|
||||
|| (int)$row['target_user_id'] !== (int)$target['target_user_id']
|
||||
) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$grantRowId = (int)$row['id'];
|
||||
$consume = $mysqli->prepare(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `consumed_at` = UTC_TIMESTAMP()
|
||||
WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($consume === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant consumption.');
|
||||
}
|
||||
$consume->bind_param('i', $grantRowId);
|
||||
$consume->execute();
|
||||
$affectedRows = $consume->affected_rows;
|
||||
$consume->close();
|
||||
if ($affectedRows !== 1) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$employeeId = (int)$row['target_user_id'];
|
||||
$token = (new authentication())->create_employee_token($employeeId);
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to exchange login grant.', 500);
|
||||
}
|
||||
|
||||
$this->audit(
|
||||
$employeeId,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED',
|
||||
'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return ['employee_id' => $employeeId, 'token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,revoked_count:int}
|
||||
*/
|
||||
public function revokeForEmployee(users_o $manager, int $employeeId): array
|
||||
{
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
||||
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `revoked_at` = UTC_TIMESTAMP()
|
||||
WHERE `target_user_id` = ?
|
||||
AND `consumed_at` IS NULL
|
||||
AND `revoked_at` IS NULL
|
||||
AND `expires_at` >= ?'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500);
|
||||
}
|
||||
$now = time();
|
||||
$statement->bind_param('ii', $employeeId, $now);
|
||||
$statement->execute();
|
||||
$revokedCount = $statement->affected_rows;
|
||||
$statement->close();
|
||||
|
||||
$this->audit(
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED',
|
||||
'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount];
|
||||
}
|
||||
|
||||
private function ttlSeconds(mixed $value): int
|
||||
{
|
||||
if (is_string($value) && ctype_digit($value)) {
|
||||
$value = (int)$value;
|
||||
}
|
||||
if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) {
|
||||
throw new limited_backoffice_exception(
|
||||
'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.',
|
||||
400
|
||||
);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findIdempotentGrant(
|
||||
int $actorUserId,
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
string $idempotencyKeyHash
|
||||
): ?array {
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`,
|
||||
`consumed_at`, `revoked_at`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `target_user_id` = ?
|
||||
AND `purpose` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500);
|
||||
}
|
||||
$statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function bearerForIdempotency(
|
||||
int $actorUserId,
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
int $ttlSeconds,
|
||||
string $idempotencyKey
|
||||
): string {
|
||||
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
|
||||
if ($key === '') {
|
||||
throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503);
|
||||
}
|
||||
return 'lbg_' . hash_hmac(
|
||||
'sha256',
|
||||
$actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey,
|
||||
$key
|
||||
);
|
||||
}
|
||||
|
||||
private function isReplayableGrant(array $row, string $secretHash): bool
|
||||
{
|
||||
return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash)
|
||||
&& ($row['consumed_at'] ?? null) === null
|
||||
&& ($row['revoked_at'] ?? null) === null
|
||||
&& (int)($row['expires_at'] ?? 0) > time();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function grantResult(
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
string $bearer,
|
||||
array $row
|
||||
): array {
|
||||
$expiresAt = (int)$row['expires_at'];
|
||||
return [
|
||||
'employee_id' => $employeeId,
|
||||
'purpose' => $purpose,
|
||||
'ttl_seconds' => max(0, $expiresAt - time()),
|
||||
'expires_at' => gmdate('c', $expiresAt),
|
||||
'one_time' => true,
|
||||
'preflight' => false,
|
||||
'grant_id' => (string)$row['grant_id'],
|
||||
// The fragment avoids ingress request logs and Referer propagation.
|
||||
'login_path' => '/login/qr#grant=' . rawurlencode($bearer),
|
||||
'exchange_path' => '/auth/limited-backoffice-login-grants/exchange',
|
||||
];
|
||||
}
|
||||
|
||||
private function duplicateGrantException(array $row): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception(
|
||||
'A login grant already exists for this idempotency key.',
|
||||
409,
|
||||
[
|
||||
'message' => 'A login grant already exists for this idempotency key.',
|
||||
'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT',
|
||||
'grant_id' => (string)$row['grant_id'],
|
||||
'employee_id' => (int)$row['target_user_id'],
|
||||
'purpose' => (string)$row['purpose'],
|
||||
'expires_at' => gmdate('c', (int)$row['expires_at']),
|
||||
'consumed' => $row['consumed_at'] !== null,
|
||||
'revoked' => $row['revoked_at'] !== null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function invalidGrantException(): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Invalid or expired login grant.', 401);
|
||||
}
|
||||
|
||||
private function lockActiveManagedEmployee(int $employeeId): bool
|
||||
{
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id`
|
||||
FROM `limited_backoffice_employees` lbe
|
||||
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
|
||||
WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to lock login grant employee.', 500);
|
||||
}
|
||||
$statement->bind_param('i', $employeeId);
|
||||
$statement->execute();
|
||||
$employee = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if ($employee === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groupId = (int)$employee['group_id'];
|
||||
$managedGroupId = (int)$employee['managed_group_id'];
|
||||
if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) {
|
||||
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
||||
}
|
||||
|
||||
// Lock the complete permission range so role changes cannot add elevated
|
||||
// capabilities between validation and token creation.
|
||||
$permissions = $this->mysqli()->prepare(
|
||||
'SELECT `permission`
|
||||
FROM `groups_permissions`
|
||||
WHERE `group_id` = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($permissions === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant role.', 500);
|
||||
}
|
||||
$permissions->bind_param('i', $groupId);
|
||||
$permissions->execute();
|
||||
$result = $permissions->get_result();
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ((string)($row['permission'] ?? '') === 'superuser') {
|
||||
$permissions->close();
|
||||
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
||||
}
|
||||
}
|
||||
$permissions->close();
|
||||
|
||||
$groupUsers = $this->mysqli()->prepare(
|
||||
'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE'
|
||||
);
|
||||
if ($groupUsers === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant group.', 500);
|
||||
}
|
||||
$groupUsers->bind_param('i', $groupId);
|
||||
$groupUsers->execute();
|
||||
$groupUserResult = $groupUsers->get_result();
|
||||
$userCount = 0;
|
||||
while ($groupUserResult->fetch_assoc() !== null) {
|
||||
$userCount++;
|
||||
}
|
||||
$groupUsers->close();
|
||||
if ($userCount !== 1) {
|
||||
throw new limited_backoffice_exception('Login grant target group is shared.', 403);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{manager:users_o,group_id:int}
|
||||
*/
|
||||
private function lockAuthorizedActor(users_o $manager): array
|
||||
{
|
||||
$actorUserId = (int)$manager->id;
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to lock login grant actor.', 500);
|
||||
}
|
||||
$statement->bind_param('i', $actorUserId);
|
||||
$statement->execute();
|
||||
$actor = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if (
|
||||
$actor === null
|
||||
|| (int)$actor['group_id'] <= 0
|
||||
|| account_deletion_service::principalIsBlocked('customer', $actorUserId)
|
||||
) {
|
||||
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
||||
}
|
||||
|
||||
$groupId = (int)$actor['group_id'];
|
||||
if ($groupId !== 1) {
|
||||
$requiredPermissions = [
|
||||
limited_backoffice_service::PERMISSION_ACCESS,
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
|
||||
];
|
||||
// Lock the actor's complete permission set, including every department_access_* row
|
||||
// consumed by assertEmployeeLoginTarget.
|
||||
// The group_id range lock prevents concurrent role replacement
|
||||
// from revoking scope between authorization and grant insertion.
|
||||
$permissions = $this->mysqli()->prepare(
|
||||
'SELECT `permission`
|
||||
FROM `groups_permissions`
|
||||
WHERE `group_id` = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($permissions === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant actor.', 500);
|
||||
}
|
||||
$permissions->bind_param('i', $groupId);
|
||||
$permissions->execute();
|
||||
$result = $permissions->get_result();
|
||||
$granted = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$granted[] = (string)$row['permission'];
|
||||
}
|
||||
$permissions->close();
|
||||
if (array_diff($requiredPermissions, $granted) !== []) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Login grant actor is no longer authorized.',
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$currentManager = (new users_o())->getUserById($actorUserId);
|
||||
if (!$currentManager->exists()) {
|
||||
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
||||
}
|
||||
return [
|
||||
'manager' => $currentManager,
|
||||
'group_id' => $groupId,
|
||||
];
|
||||
}
|
||||
|
||||
private function audit(int $actorUserId, string $event, string $message): void
|
||||
{
|
||||
try {
|
||||
(new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message);
|
||||
} catch (\Throwable) {
|
||||
// Audit logging must not expose a bearer or block the grant lifecycle.
|
||||
}
|
||||
}
|
||||
|
||||
private function mysqli(): \mysqli
|
||||
{
|
||||
global $db;
|
||||
return $db->conn();
|
||||
}
|
||||
}
|
||||
@@ -36,45 +36,6 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`grant_id` CHAR(32) NOT NULL,
|
||||
`secret_hash` CHAR(64) NOT NULL,
|
||||
`target_user_id` INT NOT NULL,
|
||||
`actor_user_id` INT NOT NULL,
|
||||
`purpose` VARCHAR(64) NOT NULL,
|
||||
`idempotency_key_hash` CHAR(64) NULL,
|
||||
`expires_at` BIGINT UNSIGNED NOT NULL,
|
||||
`consumed_at` DATETIME NULL,
|
||||
`revoked_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
|
||||
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
|
||||
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
|
||||
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` INT NOT NULL,
|
||||
`action_type` VARCHAR(64) NOT NULL,
|
||||
`idempotency_key_hash` CHAR(64) NOT NULL,
|
||||
`payload_hash` CHAR(64) NOT NULL,
|
||||
`result_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
|
||||
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
|
||||
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
@@ -628,47 +628,12 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
if ($user->hasPermission('superuser')) {
|
||||
return $this->allDepartmentIds();
|
||||
}
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
|
||||
));
|
||||
|
||||
return $this->accessibleDepartmentIdsForGroup($groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authoritative department scope only when the authenticated
|
||||
* user is managed by limited backoffice. A null result means the caller
|
||||
* must preserve the route's existing non-limited authorization semantics.
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public function managedEmployeeDepartmentIds(users_o $user): ?array
|
||||
{
|
||||
if (!$user->exists() || (int)$user->id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$managedEmployee = $this->loadManagedEmployee((int)$user->id);
|
||||
if ($managedEmployee === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decodeDepartmentIds((string)($managedEmployee['department_ids'] ?? '[]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads department scope from an authoritative group identity rather than
|
||||
* from user object properties that may be backed by a stale Redis value.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function accessibleDepartmentIdsForGroup(int $groupId): array
|
||||
{
|
||||
if ($groupId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($groupId === 1) {
|
||||
return $this->allDepartmentIds();
|
||||
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
|
||||
}
|
||||
|
||||
global $db;
|
||||
@@ -688,9 +653,6 @@ class limited_backoffice_service
|
||||
$departmentIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$permission = (string)($row['permission'] ?? '');
|
||||
if ($permission === 'superuser') {
|
||||
return $this->allDepartmentIds();
|
||||
}
|
||||
if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
@@ -704,19 +666,6 @@ class limited_backoffice_service
|
||||
return array_values(array_unique($departmentIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function allDepartmentIds(): array
|
||||
{
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
|
||||
));
|
||||
|
||||
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -774,7 +723,6 @@ class limited_backoffice_service
|
||||
return [
|
||||
'department' => $department,
|
||||
'categories' => $catalog['categories'],
|
||||
'revision' => $this->departmentPricesRevision($departmentId),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -826,17 +774,10 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
|
||||
$expectedRevision = $this->normalizeExpectedPricingRevision($payload['expected_revision'] ?? null);
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$this->lockDepartmentForPricingUpdate($departmentId);
|
||||
$currentRevision = $this->departmentPricesRevision($departmentId);
|
||||
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||
throw $this->pricingRevisionConflict($currentRevision);
|
||||
}
|
||||
|
||||
$deleteStatement = $mysqli->prepare(
|
||||
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
|
||||
);
|
||||
@@ -849,22 +790,15 @@ class limited_backoffice_service
|
||||
|
||||
foreach ($normalizedPrices as $productId => $price) {
|
||||
$deleteStatement->bind_param('ii', $departmentId, $productId);
|
||||
if (!$deleteStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to clear department price.');
|
||||
}
|
||||
$deleteStatement->execute();
|
||||
|
||||
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
|
||||
if (!$insertStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to save department price.');
|
||||
}
|
||||
$insertStatement->execute();
|
||||
}
|
||||
|
||||
$deleteStatement->close();
|
||||
$insertStatement->close();
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable $throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department prices.', 500);
|
||||
@@ -965,65 +899,11 @@ class limited_backoffice_service
|
||||
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
||||
$email = $this->normalizeEmail($payload['email'] ?? null, true);
|
||||
$phone = $this->normalizeOptionalPhonePair($payload);
|
||||
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
|
||||
if ($idempotencyKey !== '' && (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'idempotency_key must contain between 16 and 128 characters.',
|
||||
400
|
||||
);
|
||||
}
|
||||
$idempotencyKeyHash = $idempotencyKey === '' ? null : hash('sha256', $idempotencyKey);
|
||||
$payloadJson = (string)json_encode([
|
||||
'department_ids' => $departmentIds,
|
||||
'role_key' => $roleKey,
|
||||
'display_name' => $displayName,
|
||||
'password' => $password,
|
||||
'email' => $email,
|
||||
'phone_country_code' => $phone['phone_country_code'],
|
||||
'phone' => $phone['phone'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$payloadHash = $idempotencyKeyHash === null
|
||||
? null
|
||||
: hash_hmac('sha256', $payloadJson, $this->idempotencyDigestKey());
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
if ($idempotencyKeyHash !== null) {
|
||||
$replayedEmployeeId = $this->reserveEmployeeCreateIdempotency(
|
||||
(int)$manager->id,
|
||||
$idempotencyKeyHash,
|
||||
$payloadHash
|
||||
);
|
||||
if ($replayedEmployeeId !== null) {
|
||||
$employee = $this->loadManagedEmployee($replayedEmployeeId);
|
||||
if ($employee === null) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotent employee result is unavailable.',
|
||||
409
|
||||
);
|
||||
}
|
||||
$currentDepartmentIds = $this->decodeDepartmentIds(
|
||||
(string)$employee['department_ids']
|
||||
);
|
||||
$this->assertDepartmentSubset($manager, $currentDepartmentIds);
|
||||
$this->assertManagedTargetIsSafe($employee);
|
||||
if (!$this->isEmployeeRowActive($employee)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotent employee result is no longer active.',
|
||||
409
|
||||
);
|
||||
}
|
||||
$mysqli->commit();
|
||||
return $this->formatEmployee(
|
||||
$employee,
|
||||
$currentDepartmentIds,
|
||||
$this->isEmployeeRowActive($employee)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
|
||||
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
@@ -1070,35 +950,7 @@ class limited_backoffice_service
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
if ($idempotencyKeyHash !== null) {
|
||||
$statement = $mysqli->prepare(
|
||||
'UPDATE `limited_backoffice_action_idempotency`
|
||||
SET `result_user_id` = ?
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `action_type` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency result.');
|
||||
}
|
||||
$managerId = (int)$manager->id;
|
||||
$actionType = 'employee.create';
|
||||
$statement->bind_param(
|
||||
'iiss',
|
||||
$employeeId,
|
||||
$managerId,
|
||||
$actionType,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to create employee.', 500);
|
||||
@@ -1112,66 +964,6 @@ class limited_backoffice_service
|
||||
return $this->formatEmployee($employee, $departmentIds, true);
|
||||
}
|
||||
|
||||
private function reserveEmployeeCreateIdempotency(
|
||||
int $actorUserId,
|
||||
string $keyHash,
|
||||
string $payloadHash
|
||||
): ?int {
|
||||
$mysqli = $this->mysqli();
|
||||
$actionType = 'employee.create';
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `limited_backoffice_action_idempotency`
|
||||
(`actor_user_id`, `action_type`, `idempotency_key_hash`, `payload_hash`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `id` = `id`'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency reservation.');
|
||||
}
|
||||
$statement->bind_param('isss', $actorUserId, $actionType, $keyHash, $payloadHash);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'SELECT `payload_hash`, `result_user_id`
|
||||
FROM `limited_backoffice_action_idempotency`
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `action_type` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency lookup.');
|
||||
}
|
||||
$statement->bind_param('iss', $actorUserId, $actionType, $keyHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if ($row === null) {
|
||||
throw new \RuntimeException('Unable to load employee idempotency reservation.');
|
||||
}
|
||||
if (!hash_equals((string)$row['payload_hash'], $payloadHash)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotency key was already used with a different employee payload.',
|
||||
409
|
||||
);
|
||||
}
|
||||
return $row['result_user_id'] === null ? null : (int)$row['result_user_id'];
|
||||
}
|
||||
|
||||
private function idempotencyDigestKey(): string
|
||||
{
|
||||
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
|
||||
if ($key === '') {
|
||||
throw new limited_backoffice_exception(
|
||||
'Employee idempotency protection is not configured.',
|
||||
500
|
||||
);
|
||||
}
|
||||
return hash_hmac('sha256', 'limited-backoffice-employee-idempotency-v1', $key, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -1290,25 +1082,6 @@ class limited_backoffice_service
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$lock = $mysqli->prepare(
|
||||
'SELECT lbe.`user_id`
|
||||
FROM `limited_backoffice_employees` lbe
|
||||
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
|
||||
WHERE lbe.`user_id` = ? AND lbe.`managed_group_id` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($lock === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee update lock.');
|
||||
}
|
||||
$lock->bind_param('ii', $employeeId, $managedGroupId);
|
||||
$lock->execute();
|
||||
$lockedEmployee = $lock->get_result()->fetch_assoc() ?: null;
|
||||
$lock->close();
|
||||
if ($lockedEmployee === null) {
|
||||
throw new limited_backoffice_exception('Managed employee changed before update.', 409);
|
||||
}
|
||||
|
||||
if ($active) {
|
||||
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
|
||||
}
|
||||
@@ -1384,13 +1157,9 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the target and department boundary used by one-time login grants.
|
||||
* @return array{employee_id:int,login_path:string}
|
||||
*/
|
||||
public function assertEmployeeLoginTarget(
|
||||
users_o $manager,
|
||||
int $employeeId,
|
||||
?int $authoritativeGroupId = null
|
||||
): void
|
||||
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
|
||||
{
|
||||
$this->assertNotSelfEdit($manager, $employeeId);
|
||||
|
||||
@@ -1400,12 +1169,32 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds, $authoritativeGroupId);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds);
|
||||
$this->assertManagedTargetIsSafe($employee);
|
||||
|
||||
if (!$this->isEmployeeRowActive($employee)) {
|
||||
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
|
||||
}
|
||||
|
||||
$token = (new authentication())->create_employee_token($employeeId);
|
||||
|
||||
try {
|
||||
(new logs_o())->add(
|
||||
'auth',
|
||||
'global',
|
||||
1,
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
|
||||
'Created limited backoffice login link for employee: ' . $employeeId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Audit logging should not block login-link generation.
|
||||
}
|
||||
|
||||
return [
|
||||
'employee_id' => $employeeId,
|
||||
'login_path' => '/login/qr?token=' . $token,
|
||||
];
|
||||
}
|
||||
|
||||
private function mysqli(): mysqli
|
||||
@@ -1414,80 +1203,6 @@ class limited_backoffice_service
|
||||
return $db->conn();
|
||||
}
|
||||
|
||||
private function lockDepartmentForPricingUpdate(int $departmentId): void
|
||||
{
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `id` FROM `departments` WHERE `id` = ? FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare pricing update lock.');
|
||||
}
|
||||
|
||||
$statement->bind_param('i', $departmentId);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result();
|
||||
$exists = $result->num_rows > 0;
|
||||
$statement->close();
|
||||
|
||||
if (!$exists) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function departmentPricesRevision(int $departmentId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `product_id`, `price`
|
||||
FROM `product_department_prices`
|
||||
WHERE `department_id` = ?
|
||||
ORDER BY `product_id` ASC, `id` ASC'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department price revision.');
|
||||
}
|
||||
|
||||
$statement->bind_param('i', $departmentId);
|
||||
$statement->execute();
|
||||
$rows = $db->fetch_all($statement->get_result());
|
||||
$statement->close();
|
||||
|
||||
$revisionRows = array_map(static fn(array $row): array => [
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'price' => (int)$row['price'],
|
||||
], $rows);
|
||||
|
||||
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function normalizeExpectedPricingRevision(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
// Transitional compatibility for already-deployed clients. New clients
|
||||
// send the revision returned by GET and receive stale-write protection.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
|
||||
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
|
||||
'message' => 'Expected revision is invalid.',
|
||||
'code' => 'pricing_revision_invalid',
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function pricingRevisionConflict(string $currentRevision): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
|
||||
'message' => 'Pricing has changed. Reload and try again.',
|
||||
'code' => 'pricing_revision_conflict',
|
||||
'current_revision' => $currentRevision,
|
||||
]);
|
||||
}
|
||||
|
||||
private function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
$cacheKey = $table . '.' . $column;
|
||||
@@ -1737,19 +1452,13 @@ class limited_backoffice_service
|
||||
/**
|
||||
* @param array<int, int> $departmentIds
|
||||
*/
|
||||
private function assertDepartmentSubset(
|
||||
users_o $manager,
|
||||
array $departmentIds,
|
||||
?int $authoritativeGroupId = null
|
||||
): void
|
||||
private function assertDepartmentSubset(users_o $manager, array $departmentIds): void
|
||||
{
|
||||
if ($departmentIds === []) {
|
||||
throw new limited_backoffice_exception('At least one department is required.', 400);
|
||||
}
|
||||
|
||||
$managerDepartmentIds = $authoritativeGroupId === null
|
||||
? $this->accessibleDepartmentIds($manager)
|
||||
: $this->accessibleDepartmentIdsForGroup($authoritativeGroupId);
|
||||
$managerDepartmentIds = $this->accessibleDepartmentIds($manager);
|
||||
$outside = array_values(array_diff($departmentIds, $managerDepartmentIds));
|
||||
if ($outside !== []) {
|
||||
$permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside);
|
||||
@@ -2346,13 +2055,6 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
$db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId);
|
||||
$db->query(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `revoked_at` = UTC_TIMESTAMP()
|
||||
WHERE `target_user_id` = ' . (int)$userId . '
|
||||
AND `consumed_at` IS NULL
|
||||
AND `revoked_at` IS NULL'
|
||||
);
|
||||
$this->clearUserSessionCache($userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,9 @@ namespace classes;
|
||||
use Exception;
|
||||
use mysqli_result;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class module_usage_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
private module_usage_registry $registry;
|
||||
|
||||
public function __construct(?module_usage_registry $registry = null)
|
||||
@@ -973,7 +968,10 @@ class module_usage_service
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return self::normalizeBoolean($value);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function sqlString(string $value): string
|
||||
|
||||
@@ -8,6 +8,7 @@ class object_property
|
||||
private string $table; // The id of the object in the database
|
||||
private string $column; // The column name of the field in the database table (e.g. id, name, email)
|
||||
private string $type; // The data type of the field in the database table (e.g. int, varchar, text)
|
||||
private bool $required; // Whether the field is required or not
|
||||
private mixed $default; // The default value of the field
|
||||
private mixed $fake_value; // The fake value of the field, used for testing purposes (When the object id is -1)
|
||||
|
||||
@@ -17,7 +18,7 @@ class object_property
|
||||
$this->id = $id;
|
||||
$this->column = $column;
|
||||
$this->type = $type;
|
||||
unset($required); // Retained in the constructor for compatibility with existing object definitions.
|
||||
$this->required = $required;
|
||||
$this->default = $default;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,6 @@ use Exception;
|
||||
use interfaces\openai_i;
|
||||
use openAI\openAI_c;
|
||||
|
||||
class openai_request_exception extends Exception
|
||||
{
|
||||
public function __construct(string $message, public readonly bool $retryable = false, public readonly ?int $httpStatus = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
class openai implements openai_i
|
||||
{
|
||||
/**
|
||||
@@ -52,35 +44,19 @@ class openai implements openai_i
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function jsonTask(
|
||||
string $schemaName,
|
||||
string $prompt,
|
||||
array $payload,
|
||||
array $schema,
|
||||
float $temperature = 0.1,
|
||||
?string $model = null
|
||||
): array
|
||||
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array
|
||||
{
|
||||
$this->requireModuleEnabled();
|
||||
|
||||
$data = [
|
||||
'model' => $model ?? $this->model,
|
||||
// The caller owns the durable audit record. Do not retain application state at OpenAI.
|
||||
'store' => false,
|
||||
'model' => $this->model,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'developer',
|
||||
'content' => [[
|
||||
'type' => 'input_text',
|
||||
'text' => $prompt,
|
||||
]],
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'input_text',
|
||||
'text' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -97,56 +73,17 @@ class openai implements openai_i
|
||||
];
|
||||
|
||||
$response = $this->sendRequest($data);
|
||||
return self::parseJsonTaskResponse($response);
|
||||
}
|
||||
|
||||
public static function parseJsonTaskResponse(array $response): array
|
||||
{
|
||||
$status = (string)($response['status'] ?? '');
|
||||
if ($status === 'incomplete') {
|
||||
$reason = preg_replace('/[^a-z0-9_.-]/i', '', (string)($response['incomplete_details']['reason'] ?? 'unknown')) ?: 'unknown';
|
||||
throw new openai_request_exception('OpenAI response was incomplete: ' . $reason, true);
|
||||
}
|
||||
if ($status !== 'completed') {
|
||||
throw new openai_request_exception('OpenAI response did not complete.', in_array($status, ['queued', 'in_progress'], true));
|
||||
$output = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($output) || $output === '') {
|
||||
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
||||
}
|
||||
|
||||
$outputText = null;
|
||||
foreach ((array)($response['output'] ?? []) as $output) {
|
||||
foreach ((array)($output['content'] ?? []) as $content) {
|
||||
if (($content['type'] ?? null) === 'refusal') {
|
||||
throw new openai_request_exception('OpenAI refused the structured task.', false);
|
||||
}
|
||||
if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
|
||||
$outputText = (string)$content['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($outputText === null || $outputText === '') {
|
||||
throw new openai_request_exception('OpenAI completed without structured output text.', false);
|
||||
}
|
||||
$decoded = json_decode($outputText, true);
|
||||
$decoded = json_decode($output, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
throw new openai_request_exception('OpenAI returned invalid structured JSON.', false);
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
}
|
||||
$resolvedModel = trim((string)($response['model'] ?? ''));
|
||||
if ($resolvedModel === '') {
|
||||
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
|
||||
}
|
||||
$usage = (array)($response['usage'] ?? []);
|
||||
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
||||
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
||||
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
|
||||
return [
|
||||
...$decoded,
|
||||
'_openai_response_model' => $resolvedModel,
|
||||
'_openai_usage' => [
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $totalTokens,
|
||||
'service_tier' => (string)($response['service_tier'] ?? ''),
|
||||
],
|
||||
];
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
protected function getLPRSchema(): array
|
||||
@@ -341,8 +278,6 @@ class openai implements openai_i
|
||||
$curl = curl_init($this->api_url);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->config->api_key->getVariableValue()
|
||||
@@ -350,19 +285,14 @@ class openai implements openai_i
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
$response = curl_exec($curl);
|
||||
if (curl_errno($curl)) {
|
||||
$curlCode = curl_errno($curl);
|
||||
curl_close($curl);
|
||||
throw new openai_request_exception('OpenAI transport failed.', in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true));
|
||||
throw new Exception('cURL error: ' . curl_error($curl));
|
||||
}
|
||||
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$responseData = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new openai_request_exception('OpenAI returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
|
||||
throw new openai_request_exception('OpenAI request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
}
|
||||
//print_r($responseData);
|
||||
return $responseData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class order_item_reason_policy
|
||||
{
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
|
||||
|
||||
public static function reasons(): array
|
||||
{
|
||||
return [
|
||||
'customer_approved_extra_work' => [
|
||||
'label' => 'Kunde godkendte ekstra arbejde',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'vehicle_condition_extra_work' => [
|
||||
'label' => 'Køretøjets tilstand krævede ekstra tid',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'quality_rework' => [
|
||||
'label' => 'Kvalitetsopfølgning eller omvask',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'legacy_note_only' => [
|
||||
'label' => 'Legacy note only',
|
||||
'requires_comment' => true,
|
||||
'active' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function productRequiresReason(int $productId): bool
|
||||
{
|
||||
return in_array($productId, self::AFFECTED_PRODUCT_IDS, true);
|
||||
}
|
||||
|
||||
public static function validateForProduct(int $productId, array $data): array
|
||||
{
|
||||
if (!self::productRequiresReason($productId)) {
|
||||
return ['reason_code' => null, 'reason_label_snapshot' => null, 'reason_comment' => null];
|
||||
}
|
||||
|
||||
$code = trim((string)($data['reason_code'] ?? $data['order_item_reason_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
throw new InvalidArgumentException('Reason code is required for this product');
|
||||
}
|
||||
|
||||
$reasons = self::reasons();
|
||||
if (!array_key_exists($code, $reasons)) {
|
||||
throw new InvalidArgumentException('Reason code is invalid for this product');
|
||||
}
|
||||
|
||||
$reason = $reasons[$code];
|
||||
if (!$reason['active']) {
|
||||
throw new InvalidArgumentException('Reason code is deprecated for this product');
|
||||
}
|
||||
|
||||
$comment = trim((string)($data['reason_comment'] ?? $data['comment'] ?? $data['notes'] ?? ''));
|
||||
if ($reason['requires_comment'] && $comment === '') {
|
||||
throw new InvalidArgumentException('Reason comment is required for this product');
|
||||
}
|
||||
|
||||
$snapshot = $reason['label'];
|
||||
|
||||
return ['reason_code' => $code, 'reason_label_snapshot' => $snapshot, 'reason_comment' => $comment];
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class order_payment_lock
|
||||
{
|
||||
private const LOCK_TIMEOUT_SECONDS = 10;
|
||||
private const ORDER_RESOURCE = 'order-payment-v1';
|
||||
private const INVOICE_COLLECTION_RESOURCE = 'invoice-collection-payment-v1';
|
||||
|
||||
/** @var list<string> */
|
||||
private array $lockNames = [];
|
||||
|
||||
public static function tryAcquire(int $orderId): ?self
|
||||
{
|
||||
return self::tryAcquireResource(self::ORDER_RESOURCE, $orderId);
|
||||
}
|
||||
|
||||
public static function tryAcquireInvoiceCollection(int $invoiceCollectionId): ?self
|
||||
{
|
||||
return self::tryAcquireResource(self::INVOICE_COLLECTION_RESOURCE, $invoiceCollectionId);
|
||||
}
|
||||
|
||||
public static function tryAcquireOrderMutation(int $orderId): ?self
|
||||
{
|
||||
return self::tryAcquireOrderMutations([$orderId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $orderIds
|
||||
*/
|
||||
public static function tryAcquireOrderMutations(array $orderIds): ?self
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $orderIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
$collectionByOrder = [];
|
||||
foreach ($ids as $id) {
|
||||
$collectionByOrder[$id] = self::invoiceCollectionIdForOrder($id);
|
||||
}
|
||||
$collectionIds = array_values(array_unique(array_filter(
|
||||
$collectionByOrder,
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($collectionIds, SORT_NUMERIC);
|
||||
$resources = array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$collectionIds
|
||||
);
|
||||
array_push(
|
||||
$resources,
|
||||
...array_map(
|
||||
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
|
||||
$ids
|
||||
)
|
||||
);
|
||||
$lock = self::tryAcquireNames($resources);
|
||||
if ($lock !== null) {
|
||||
foreach ($collectionByOrder as $id => $invoiceCollectionId) {
|
||||
if (self::invoiceCollectionIdForOrder($id) !== $invoiceCollectionId) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
public static function tryAcquireReassignment(int $orderId, int $targetInvoiceCollectionId): ?self
|
||||
{
|
||||
$sourceInvoiceCollectionId = self::invoiceCollectionIdForOrder($orderId);
|
||||
$collectionIds = array_values(array_unique(array_filter([
|
||||
$sourceInvoiceCollectionId,
|
||||
$targetInvoiceCollectionId,
|
||||
], static fn(int $id): bool => $id > 0)));
|
||||
sort($collectionIds, SORT_NUMERIC);
|
||||
$resources = array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$collectionIds
|
||||
);
|
||||
$resources[] = self::resourceName(self::ORDER_RESOURCE, $orderId);
|
||||
$lock = self::tryAcquireNames($resources);
|
||||
if ($lock !== null && self::invoiceCollectionIdForOrder($orderId) !== $sourceInvoiceCollectionId) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $invoiceCollectionIds
|
||||
*/
|
||||
public static function tryAcquireInvoiceCollections(array $invoiceCollectionIds): ?self
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $invoiceCollectionIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
return self::tryAcquireNames(array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$ids
|
||||
));
|
||||
}
|
||||
|
||||
public static function tryAcquireInvoiceCollectionWithOrders(int $invoiceCollectionId): ?self
|
||||
{
|
||||
$lock = self::tryAcquireInvoiceCollection($invoiceCollectionId);
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$lock->acquireNames(array_map(
|
||||
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
|
||||
self::orderIdsForInvoiceCollection($invoiceCollectionId)
|
||||
));
|
||||
return $lock;
|
||||
} catch (UnexpectedValueException) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryAcquireResource(string $resource, int $resourceId): ?self
|
||||
{
|
||||
return self::tryAcquireNames([self::resourceName($resource, $resourceId)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private static function tryAcquireNames(array $lockNames): ?self
|
||||
{
|
||||
try {
|
||||
return new self($lockNames);
|
||||
} catch (UnexpectedValueException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function resourceName(string $resource, int $resourceId): string
|
||||
{
|
||||
if ($resourceId <= 0) {
|
||||
throw new RuntimeException('A valid resource ID is required for the payment lock.');
|
||||
}
|
||||
return $resource . ':' . $resourceId;
|
||||
}
|
||||
|
||||
private static function invoiceCollectionIdForOrder(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
if ($orderId <= 0) {
|
||||
throw new RuntimeException('A valid order ID is required for the payment lock.');
|
||||
}
|
||||
$result = $db->query(
|
||||
'SELECT `invoice_collection_id` FROM `orders` WHERE `id` = ' . $orderId . ' LIMIT 1'
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return (int)($row['invoice_collection_id'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function orderIdsForInvoiceCollection(int $invoiceCollectionId): array
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
'SELECT `id` FROM `orders` WHERE `invoice_collection_id` = '
|
||||
. $invoiceCollectionId . ' ORDER BY `id` ASC'
|
||||
);
|
||||
$ids = [];
|
||||
while ($result && ($row = $result->fetch_assoc())) {
|
||||
$ids[] = (int)$row['id'];
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private function __construct(array $lockNames)
|
||||
{
|
||||
$this->acquireNames($lockNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private function acquireNames(array $lockNames): void
|
||||
{
|
||||
global $db;
|
||||
foreach ($lockNames as $lockName) {
|
||||
if (in_array($lockName, $this->lockNames, true)) {
|
||||
continue;
|
||||
}
|
||||
$statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired');
|
||||
if ($statement === false) {
|
||||
$this->release();
|
||||
throw new RuntimeException('Unable to prepare the order payment lock.');
|
||||
}
|
||||
$timeout = self::LOCK_TIMEOUT_SECONDS;
|
||||
$statement->bind_param('si', $lockName, $timeout);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result()->fetch_assoc();
|
||||
$statement->close();
|
||||
if ((int)($result['acquired'] ?? 0) !== 1) {
|
||||
$this->release();
|
||||
throw new UnexpectedValueException(
|
||||
'The order or invoice collection is currently being changed or paid. Try again.'
|
||||
);
|
||||
}
|
||||
$this->lockNames[] = $lockName;
|
||||
}
|
||||
}
|
||||
|
||||
public function release(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
foreach (array_reverse($this->lockNames) as $lockName) {
|
||||
$statement = $db->prepare('SELECT RELEASE_LOCK(?)');
|
||||
if ($statement !== false) {
|
||||
$statement->bind_param('s', $lockName);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
}
|
||||
$this->lockNames = [];
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->release();
|
||||
}
|
||||
}
|
||||
@@ -41,38 +41,11 @@ class orders_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (self::tableExists($db, 'order_items')) {
|
||||
if (!self::columnExists($db, 'order_items', 'reason_code')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_code VARCHAR(64) NULL DEFAULT NULL
|
||||
AFTER include_in_invoice"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'order_items', 'reason_label_snapshot')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_label_snapshot VARCHAR(255) NULL DEFAULT NULL
|
||||
AFTER reason_code"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'order_items', 'reason_comment')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_comment TEXT NULL
|
||||
AFTER reason_label_snapshot"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::backfillBookingPoDefaults($db);
|
||||
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_reason_code', 'reason_code');
|
||||
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
@@ -33,31 +33,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,12 @@ use objects\ratelimit_o;
|
||||
class ratelimit implements ratelimit_i
|
||||
{
|
||||
private int $limit; // The number of requests allowed in the time period
|
||||
private int $time; // The time period in seconds
|
||||
|
||||
public function __construct(int $defaultLimit, int $defaultTime)
|
||||
{
|
||||
$this->limit = $defaultLimit;
|
||||
unset($defaultTime); // The reset interval is managed by the rate-limit maintenance task.
|
||||
$this->time = $defaultTime;
|
||||
}
|
||||
|
||||
public function enforceIP(string $ip): bool
|
||||
@@ -25,4 +26,4 @@ class ratelimit implements ratelimit_i
|
||||
$ratelimit->increment($ratelimit->id, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,10 +339,10 @@ class redis implements redis_i
|
||||
/**
|
||||
* Cache auth session payload for a token with TTL
|
||||
*/
|
||||
public function cache_auth_session(string $token, array $session, int $ttl = 60): self
|
||||
public function cache_auth_session(string $token, array $data, int $ttl = 60): self
|
||||
{
|
||||
$key = 'auth_session_' . $token;
|
||||
$this->set_array($key, $session);
|
||||
$this->set_array($key, $data);
|
||||
$this->expire($key, $ttl);
|
||||
return $this;
|
||||
}
|
||||
@@ -636,24 +636,6 @@ class redis implements redis_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increments a fixed-window counter and assigns its TTL on the
|
||||
* first increment. This avoids the GET/SET race in public abuse controls.
|
||||
*/
|
||||
public function incrementWithExpiration(string $key, int $seconds): int
|
||||
{
|
||||
if (!self::is_connected()) {
|
||||
self::connect();
|
||||
}
|
||||
|
||||
$count = (int)$this->redis->incr($key);
|
||||
if ($count === 1) {
|
||||
$this->redis->expire($key, max(1, $seconds));
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function generateTemporaryCacheKey(): string
|
||||
{
|
||||
// Generate a temporary cache key
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class releasemanager
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
@@ -17,7 +11,7 @@ class releasemanager
|
||||
global $db;
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return self::normalizeBoolean((string)($row['value'] ?? 'true'));
|
||||
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,9 @@ use Aws\S3\S3Client;
|
||||
use mysqli;
|
||||
use Predis\Client as PredisClient;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class replica_failover_manager
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public const KIND_DATABASE = 'database';
|
||||
public const KIND_REDIS = 'redis';
|
||||
public const KIND_MINIO = 'minio';
|
||||
@@ -507,7 +502,11 @@ class replica_failover_manager
|
||||
|
||||
private static function boolValue(mixed $value): bool
|
||||
{
|
||||
return self::normalizeBoolean($value);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private static function jsonDecode(mixed $value): array
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Self-healing schema bootstrap.
|
||||
*
|
||||
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
|
||||
* production database always has the columns the current code expects.
|
||||
* This catches the "merged-to-master-but-never-applied-to-prod" failure
|
||||
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
|
||||
* step didn't run (missing GitHub secrets, network glitch, etc.).
|
||||
*
|
||||
* Each bootstrap is **additive + idempotent**:
|
||||
* - SHOW COLUMNS check before any ALTER
|
||||
* - ALTER TABLE ADD COLUMN only if missing
|
||||
* - Once `ensureSchema()` has been called once for a class, the static
|
||||
* `$initialized` flag short-circuits subsequent calls
|
||||
*
|
||||
* The discovery + run loop itself is memoized per PHP process via
|
||||
* `self::$ran`, so the cost after the first request is a single
|
||||
* `class_exists` check (~microseconds).
|
||||
*
|
||||
* Errors in a single bootstrap are logged but never throw — a broken
|
||||
* migration must not 500 every request. A future /api/admin/schema-check
|
||||
* call will surface the failure.
|
||||
*/
|
||||
class schema_bootstrap_runtime
|
||||
{
|
||||
/** @var bool Memoization for the discovery+run loop */
|
||||
private static bool $ran = false;
|
||||
|
||||
/** @var string[] Class names that already failed this process (don't retry) */
|
||||
private static array $failed = [];
|
||||
|
||||
public static function runAll(): void
|
||||
{
|
||||
if (self::$ran) {
|
||||
return;
|
||||
}
|
||||
self::$ran = true;
|
||||
|
||||
$classesDir = __DIR__;
|
||||
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($bootstraps as $file) {
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
|
||||
if (in_array($class, self::$failed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!class_exists($class)) {
|
||||
require_once $file;
|
||||
}
|
||||
if (!class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
continue;
|
||||
}
|
||||
$class::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
self::$failed[] = $class;
|
||||
error_log(sprintf(
|
||||
'[schema-bootstrap] %s failed: %s',
|
||||
$base,
|
||||
$e->getMessage()
|
||||
));
|
||||
// Intentionally do not throw — a broken migration must
|
||||
// not 500 every request. The next /api/admin/schema-check
|
||||
// call (or the next deploy's pre-deploy step) will
|
||||
// surface the failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,17 +33,17 @@ class slack implements notification_i
|
||||
public function send_department_booking_notification(int $department_id, $message): self
|
||||
{
|
||||
// Get the departments webhook
|
||||
$webhook = static::get_department_webhook($department_id);
|
||||
$webhook = self::get_department_webhook($department_id);
|
||||
// Check if the webhook is empty
|
||||
if (empty($webhook)) {
|
||||
throw new \Exception('Department webhook is empty');
|
||||
}
|
||||
// Send the notification to the department
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
self::add_log(self::send_webhook_message($message, $webhook));
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function get_department_webhook(int $department_id): string|null
|
||||
private function get_department_webhook(int $department_id): string|null
|
||||
{
|
||||
// Check if the department webhook is cached
|
||||
$webhook = redis->get_department_webhook($department_id);
|
||||
@@ -134,68 +134,6 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a new-booking notification to the department's Slack webhook.
|
||||
*
|
||||
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
|
||||
* bookings (pickup_bool === false) are intentionally silenced per
|
||||
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
|
||||
* channel. Other delivery channels (SMS, email) are unaffected.
|
||||
*
|
||||
* Returns true if a Slack message was sent, false if it was filtered
|
||||
* out (drop-off) or the department has no Slack webhook configured.
|
||||
*
|
||||
* @throws \Exception If the department lookup or webhook send fails.
|
||||
*/
|
||||
public function send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
string $wash_type,
|
||||
string $contact_email,
|
||||
string $reference_number,
|
||||
string $regNrTraekker,
|
||||
string $regNrTrailer,
|
||||
string $washCertificateEmail,
|
||||
string $date,
|
||||
int $department,
|
||||
bool $pickup_bool,
|
||||
string $notes,
|
||||
string $washCertificateStatus,
|
||||
string $washCertificateUrl,
|
||||
string $status
|
||||
): bool {
|
||||
// TRU-106: drop-off bookings must not post to Slack.
|
||||
if (!$pickup_bool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$webhook = static::get_department_webhook($department);
|
||||
if (empty($webhook)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = static::format_new_booking(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
$contact_email,
|
||||
$reference_number,
|
||||
$regNrTraekker,
|
||||
$regNrTrailer,
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
);
|
||||
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Purpose-bound one-time tokens. Only SHA-256 digests are persisted.
|
||||
*/
|
||||
class subuser_action_token_service
|
||||
{
|
||||
public const PURPOSE_GRANT_APPROVE = 'grant_approve';
|
||||
public const PURPOSE_GRANT_DENY = 'grant_deny';
|
||||
public const PURPOSE_PASSWORD_RESET = 'password_reset';
|
||||
public const TOKEN_BYTES = 32;
|
||||
public const GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60;
|
||||
public const PASSWORD_RESET_TTL_SECONDS = 60 * 60;
|
||||
|
||||
public function issue(string $purpose, int $subuserId, ?int $grantId = null, ?int $customerNumber = null, ?int $ttlSeconds = null): string
|
||||
{
|
||||
global $db;
|
||||
$this->assertPurpose($purpose);
|
||||
if ($subuserId <= 0) {
|
||||
throw new Exception('Invalid subuser action token subject');
|
||||
}
|
||||
$token = bin2hex(random_bytes(self::TOKEN_BYTES));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$ttlSeconds ??= $purpose === self::PURPOSE_PASSWORD_RESET
|
||||
? self::PASSWORD_RESET_TTL_SECONDS
|
||||
: self::GRANT_DECISION_TTL_SECONDS;
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + max(60, $ttlSeconds));
|
||||
$statement = $db->conn->prepare(
|
||||
'INSERT INTO subuser_action_tokens '
|
||||
. '(token_hash, purpose, subuser_id, grant_id, customer_number, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new Exception('Failed to prepare subuser action token');
|
||||
}
|
||||
$statement->bind_param('ssiiis', $tokenHash, $purpose, $subuserId, $grantId, $customerNumber, $expiresAt);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function inspect(string $token, ?string $expectedPurpose = null): ?array
|
||||
{
|
||||
global $db;
|
||||
$token = strtolower($token);
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
if ($expectedPurpose !== null) {
|
||||
$this->assertPurpose($expectedPurpose);
|
||||
}
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$sql = 'SELECT id, purpose, subuser_id, grant_id, customer_number, expires_at FROM subuser_action_tokens '
|
||||
. "WHERE token_hash = '" . $db->escape_string($tokenHash) . "' "
|
||||
. 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()';
|
||||
if ($expectedPurpose !== null) {
|
||||
$sql .= " AND purpose = '" . $db->escape_string($expectedPurpose) . "'";
|
||||
}
|
||||
$result = $db->query($sql . ' LIMIT 1');
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'purpose' => (string)$row['purpose'],
|
||||
'subuser_id' => (int)$row['subuser_id'],
|
||||
'grant_id' => $row['grant_id'] === null ? null : (int)$row['grant_id'],
|
||||
'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'],
|
||||
'expires_at' => (string)$row['expires_at'],
|
||||
];
|
||||
}
|
||||
|
||||
public function consume(string $token, string $expectedPurpose): ?array
|
||||
{
|
||||
global $db;
|
||||
$record = $this->inspect($token, $expectedPurpose);
|
||||
if ($record === null) {
|
||||
return null;
|
||||
}
|
||||
$statement = $db->conn->prepare(
|
||||
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
|
||||
. 'WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new Exception('Failed to consume subuser action token');
|
||||
}
|
||||
$id = (int)$record['id'];
|
||||
$statement->bind_param('i', $id);
|
||||
$statement->execute();
|
||||
$consumed = $statement->affected_rows === 1;
|
||||
$statement->close();
|
||||
return $consumed ? $record : null;
|
||||
}
|
||||
|
||||
public function consumeGrantDecision(string $token): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$preview = $this->inspect($token);
|
||||
if (
|
||||
$preview === null
|
||||
|| $preview['grant_id'] === null
|
||||
|| !in_array($preview['purpose'], [
|
||||
self::PURPOSE_GRANT_APPROVE,
|
||||
self::PURPOSE_GRANT_DENY,
|
||||
], true)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$db->conn->begin_transaction();
|
||||
try {
|
||||
// Serializing on the grant row prevents simultaneous approve and
|
||||
// deny links from racing and applying opposite final states.
|
||||
$lock = $db->conn->prepare(
|
||||
'SELECT id FROM subuser_grants '
|
||||
. 'WHERE id = ? AND subuser = ? AND billing_customer_number = ? AND deleted_at IS NULL '
|
||||
. 'FOR UPDATE'
|
||||
);
|
||||
if ($lock === false) {
|
||||
throw new Exception('Failed to lock subuser grant decision');
|
||||
}
|
||||
$grantId = (int)$preview['grant_id'];
|
||||
$subuserId = (int)$preview['subuser_id'];
|
||||
$customerNumber = (int)$preview['customer_number'];
|
||||
$lock->bind_param('iii', $grantId, $subuserId, $customerNumber);
|
||||
$lock->execute();
|
||||
$lock->store_result();
|
||||
$grantExists = $lock->num_rows === 1;
|
||||
$lock->close();
|
||||
if (!$grantExists) {
|
||||
$db->conn->rollback();
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->consume($token, (string)$preview['purpose']);
|
||||
if ($record === null) {
|
||||
$db->conn->rollback();
|
||||
return null;
|
||||
}
|
||||
$enabled = $record['purpose'] === self::PURPOSE_GRANT_APPROVE ? 1 : 0;
|
||||
$update = $db->conn->prepare('UPDATE subuser_grants SET enabled = ? WHERE id = ?');
|
||||
if ($update === false) {
|
||||
throw new Exception('Failed to apply subuser grant decision');
|
||||
}
|
||||
$update->bind_param('ii', $enabled, $grantId);
|
||||
$update->execute();
|
||||
$applied = $update->affected_rows === 1 || $update->warning_count === 0;
|
||||
$update->close();
|
||||
if (!$applied) {
|
||||
throw new Exception('Failed to apply subuser grant decision');
|
||||
}
|
||||
$this->revokeGrantDecisions($grantId);
|
||||
$db->conn->commit();
|
||||
return $record;
|
||||
} catch (Exception $exception) {
|
||||
$db->conn->rollback();
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeForSubuser(int $subuserId, string $purpose): void
|
||||
{
|
||||
global $db;
|
||||
$this->assertPurpose($purpose);
|
||||
$statement = $db->conn->prepare(
|
||||
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() WHERE subuser_id = ? AND purpose = ? AND used_at IS NULL'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new Exception('Failed to revoke subuser action tokens');
|
||||
}
|
||||
$statement->bind_param('is', $subuserId, $purpose);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
|
||||
public function revokeGrantDecisions(int $grantId): void
|
||||
{
|
||||
global $db;
|
||||
$statement = $db->conn->prepare(
|
||||
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
|
||||
. 'WHERE grant_id = ? AND purpose IN (?, ?) AND used_at IS NULL'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new Exception('Failed to revoke grant decision tokens');
|
||||
}
|
||||
$approve = self::PURPOSE_GRANT_APPROVE;
|
||||
$deny = self::PURPOSE_GRANT_DENY;
|
||||
$statement->bind_param('iss', $grantId, $approve, $deny);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
|
||||
private function assertPurpose(string $purpose): void
|
||||
{
|
||||
if (!in_array($purpose, [
|
||||
self::PURPOSE_GRANT_APPROVE,
|
||||
self::PURPOSE_GRANT_DENY,
|
||||
self::PURPOSE_PASSWORD_RESET,
|
||||
], true)) {
|
||||
throw new Exception('Invalid subuser action token purpose');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,41 +38,10 @@ class subusers_schema_bootstrap
|
||||
'email_verified_at',
|
||||
'DATETIME NULL AFTER `email`'
|
||||
);
|
||||
self::ensureTable(
|
||||
'subuser_action_tokens',
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `subuser_action_tokens` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`token_hash` CHAR(64) NOT NULL,
|
||||
`purpose` VARCHAR(32) NOT NULL,
|
||||
`subuser_id` INT UNSIGNED NOT NULL,
|
||||
`grant_id` INT UNSIGNED NULL,
|
||||
`customer_number` INT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`used_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`),
|
||||
KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`),
|
||||
KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureTable(string $table, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
$db->query($definition);
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -4,14 +4,9 @@ namespace classes;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class superuser_system_status_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public const MODULE_PROBE_TTL_SECONDS = 60;
|
||||
public const REFRESH_AFTER_SECONDS = 30;
|
||||
private const MODULE_PROBE_CACHE_KEY_PREFIX = 'superuser_system_status:module_probe:';
|
||||
@@ -531,6 +526,7 @@ class superuser_system_status_service
|
||||
'enabled' => $enabled,
|
||||
'configured' => $configured,
|
||||
'probe_supported' => isset($descriptor['probe']),
|
||||
'status' => 'configured',
|
||||
'status_reason' => null,
|
||||
'status_reason_key' => null,
|
||||
'status_reason_params' => [],
|
||||
@@ -852,7 +848,7 @@ class superuser_system_status_service
|
||||
protected function parseModuleConfigValue(string $type, mixed $value): mixed
|
||||
{
|
||||
return match (strtolower($type)) {
|
||||
'bool' => self::normalizeBoolean($value),
|
||||
'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true),
|
||||
'int', 'integer' => is_numeric($value) ? (int)$value : null,
|
||||
'float', 'double' => is_numeric($value) ? (float)$value : null,
|
||||
'json' => is_string($value) ? json_decode($value, true) : null,
|
||||
|
||||
@@ -671,7 +671,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $entityIds
|
||||
* @return array<string, array{name: ?string, created_at: ?string, closed_at: ?string}>
|
||||
* @return array<string, array{name:?string,created_at:?string,closed_at:?string}>
|
||||
*/
|
||||
private function loadInvoiceTitleContexts(array $entityIds): array
|
||||
{
|
||||
@@ -2283,7 +2283,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
|
||||
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
|
||||
*/
|
||||
private function resolveObjectSearchContext(array $row): array
|
||||
{
|
||||
@@ -2296,7 +2296,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
|
||||
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
|
||||
*/
|
||||
private function resolveOrderObjectSearchContext(array $row): array
|
||||
{
|
||||
@@ -2336,7 +2336,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
|
||||
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
|
||||
*/
|
||||
private function resolveTaskObjectSearchContext(array $row): array
|
||||
{
|
||||
@@ -2379,7 +2379,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
|
||||
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
|
||||
*/
|
||||
private function resolveGenericObjectSearchContext(array $row): array
|
||||
{
|
||||
|
||||
@@ -96,6 +96,7 @@ class webauthn
|
||||
$pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value()));
|
||||
error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
|
||||
throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
|
||||
return false;
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new Exception('Serialization error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -81,7 +81,10 @@ class wordpress_bookings_remote implements wordpress_bookings_remote_i
|
||||
} else {
|
||||
$booking = $booking["data"]["booking"];
|
||||
}
|
||||
} else if (!isset($booking["id"])) {
|
||||
} else if (isset($booking["id"])) {
|
||||
// Check if the booking property is set
|
||||
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,4 +157,4 @@ class wordpress_bookings_remote implements wordpress_bookings_remote_i
|
||||
// Get the booking cache
|
||||
return $this->booking_cache;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ namespace classes;
|
||||
*/
|
||||
class xlvask_usage_logs_schema_bootstrap
|
||||
{
|
||||
public const MIGRATION_VERSION = '20260804_xlvask_ai_auto_policy_v2';
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
@@ -15,34 +14,15 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
$status = self::migrationStatus();
|
||||
if (!$status['ready']) {
|
||||
throw new \RuntimeException(
|
||||
'XL Vask automation schema is not ready. Apply migration ' . self::MIGRATION_VERSION . ' explicitly.'
|
||||
);
|
||||
}
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit operator-invoked migration entrypoint. Request handlers and workers must never call this method.
|
||||
*/
|
||||
public static function applyExplicitMigration(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
throw new \RuntimeException('The database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
||||
throw new \RuntimeException('The xlvask_usage_logs table is unavailable.');
|
||||
}
|
||||
$conflicts = self::activeExecuteRunConflicts($db);
|
||||
if ($conflicts !== []) {
|
||||
throw new \RuntimeException(
|
||||
'XL Vask automation migration is blocked by existing active execute runs: ' . implode(', ', $conflicts)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
||||
@@ -51,148 +31,9 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
|
||||
self::ensureAutomationTables($db);
|
||||
|
||||
self::$initialized = false;
|
||||
$status = self::migrationStatus();
|
||||
if (!$status['ready']) {
|
||||
throw new \RuntimeException('XL Vask automation migration did not reach a ready state.');
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
/** Read-only preflight used by readiness endpoints and normal request/worker entrypoints. */
|
||||
public static function migrationStatus(): array
|
||||
{
|
||||
global $db;
|
||||
$missingTables = [];
|
||||
$missingColumns = [];
|
||||
$requiredIndexes = [
|
||||
'xlvask_autopilot_runs.uniq_xlvask_active_execute_run',
|
||||
'xlvask_autopilot_runs.uniq_xlvask_autopilot_run_idempotency',
|
||||
'xlvask_automation_action_events.uniq_xlvask_action_event_suggestion',
|
||||
];
|
||||
$missingIndexes = [];
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return [
|
||||
'version' => self::MIGRATION_VERSION,
|
||||
'ready' => false,
|
||||
'missing_tables' => ['database'],
|
||||
'missing_columns' => [],
|
||||
'required_indexes' => $requiredIndexes,
|
||||
'missing_indexes' => $requiredIndexes,
|
||||
'preflight_conflicts' => ['database_unavailable'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'xlvask_usage_logs', 'xlvask_automation_suggestions', 'xlvask_automation_feedback',
|
||||
'xlvask_automation_openai_cache', 'xlvask_autopilot_runs', 'xlvask_autopilot_run_items',
|
||||
'xlvask_automation_audit', 'xlvask_automation_calibrations',
|
||||
'xlvask_automation_calibration_label_events', 'xlvask_automation_decision_previews',
|
||||
'xlvask_automation_policy_state', 'xlvask_automation_policy_previews',
|
||||
'xlvask_automation_policy_events', 'xlvask_automation_action_events',
|
||||
] as $table) {
|
||||
if (!self::tableExists($db, $table)) {
|
||||
$missingTables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
$requiredColumns = [
|
||||
'xlvask_usage_logs' => [
|
||||
'ignored_at', 'ignored_by', 'ignored_reason', 'cached_total_net_amount',
|
||||
'cached_primary_product_name', 'cached_amount_at', 'source_hash', 'source_revision',
|
||||
'source_observed_at', 'source_stable_since', 'source_observation_count', 'import_state',
|
||||
'resolution_state', 'certainty', 'planned_action', 'state_reason', 'expected_version',
|
||||
'last_run_id', 'last_evaluated_at',
|
||||
],
|
||||
'xlvask_automation_suggestions' => [
|
||||
'run_id', 'policy_version', 'planner_identity_hash', 'model', 'model_confidence',
|
||||
'calibrated_probability', 'certainty', 'evidence_json', 'contradictions_json',
|
||||
'risk_flags_json', 'plan_steps_json', 'expected_version', 'input_hash',
|
||||
],
|
||||
'xlvask_autopilot_runs' => [
|
||||
'idempotency_key', 'mode', 'status', 'phase', 'date_from', 'date_to', 'force_refetch',
|
||||
'requested_ids_json', 'requested_limit', 'request_hash', 'scope_hall_ids_json',
|
||||
'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token',
|
||||
'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by',
|
||||
'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot',
|
||||
'ai_timeline', 'ai_batch_size', 'ai_max_cost_usd',
|
||||
'ai_input_usd_per_1m_usd', 'ai_output_usd_per_1m_usd',
|
||||
'ai_requests', 'ai_cache_hits', 'ai_input_tokens', 'ai_output_tokens',
|
||||
'ai_total_tokens', 'ai_estimated_cost_usd', 'ai_budget_exhausted',
|
||||
],
|
||||
'xlvask_autopilot_run_items' => [
|
||||
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
|
||||
'planned_action', 'source_hash', 'expected_version', 'result_json', 'error', 'created_at', 'updated_at',
|
||||
],
|
||||
'xlvask_automation_calibrations' => [
|
||||
'policy_version', 'segment_key', 'automation_identity_hash', 'precision_value',
|
||||
'wilson_lower_bound', 'holdout_examples', 'segment_examples', 'contradictions',
|
||||
'calibrated_probability', 'artifact_hash', 'active', 'backtest_json', 'created_by',
|
||||
'activated_by', 'activated_at', 'invalidated_at', 'created_at',
|
||||
],
|
||||
'xlvask_automation_calibration_label_events' => [
|
||||
'suggestion_id', 'outcome', 'adjudication_outcome', 'adjudicated_by',
|
||||
'adjudicated_at', 'legacy_label_id',
|
||||
],
|
||||
'xlvask_automation_policy_state' => [
|
||||
'policy_version', 'planner_identity_hash', 'stage', 'halted', 'attach_enabled',
|
||||
'create_enabled', 'halt_reason', 'attach_halt_reason', 'create_halt_reason',
|
||||
'halted_at', 'halted_by', 'attach_activated_at', 'attach_activated_by',
|
||||
'create_activated_at', 'create_activated_by', 'expected_version', 'created_at', 'updated_at',
|
||||
],
|
||||
'xlvask_automation_policy_previews' => [
|
||||
'selection_hash', 'requested_transition', 'payload_json', 'created_by',
|
||||
'expires_at', 'applied_at', 'created_at',
|
||||
],
|
||||
'xlvask_automation_policy_events' => ['event_type', 'actor_id', 'details_json', 'created_at'],
|
||||
'xlvask_automation_action_events' => [
|
||||
'suggestion_id', 'run_id', 'hall_id', 'action', 'source', 'policy_version',
|
||||
'planner_identity_hash', 'review_outcome', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||
],
|
||||
];
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
if (!self::tableExists($db, $table)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($columns as $column) {
|
||||
if (!self::columnExists($db, $table, $column)) {
|
||||
$missingColumns[] = $table . '.' . $column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredIndexes as $requiredIndex) {
|
||||
[$table, $index] = explode('.', $requiredIndex, 2);
|
||||
if (!self::indexExists($db, $table, $index)) {
|
||||
$missingIndexes[] = $requiredIndex;
|
||||
}
|
||||
}
|
||||
$conflicts = self::activeExecuteRunConflicts($db);
|
||||
|
||||
return [
|
||||
'version' => self::MIGRATION_VERSION,
|
||||
'ready' => $missingTables === [] && $missingColumns === [] && $missingIndexes === [] && $conflicts === [],
|
||||
'missing_tables' => $missingTables,
|
||||
'missing_columns' => $missingColumns,
|
||||
'required_indexes' => $requiredIndexes,
|
||||
'missing_indexes' => $missingIndexes,
|
||||
'preflight_conflicts' => $conflicts,
|
||||
];
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureAutomationTables(object $db): void
|
||||
@@ -227,24 +68,6 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
foreach ([
|
||||
'run_id' => 'BIGINT NULL AFTER usage_log_id',
|
||||
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
|
||||
'planner_identity_hash' => 'CHAR(64) NULL AFTER policy_version',
|
||||
'model' => 'VARCHAR(96) NULL AFTER planner_identity_hash',
|
||||
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
|
||||
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
|
||||
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
|
||||
'evidence_json' => 'LONGTEXT NULL AFTER certainty',
|
||||
'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json',
|
||||
'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json',
|
||||
'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json',
|
||||
'expected_version' => 'INT NULL AFTER plan_steps_json',
|
||||
'input_hash' => 'CHAR(64) NULL AFTER expected_version',
|
||||
] as $column => $definition) {
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition);
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
@@ -281,375 +104,15 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`idempotency_key` CHAR(64) NOT NULL,
|
||||
`mode` VARCHAR(16) NOT NULL,
|
||||
`status` VARCHAR(24) NOT NULL DEFAULT 'queued',
|
||||
`phase` VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
`date_from` DATE NULL,
|
||||
`date_to` DATE NULL,
|
||||
`force_refetch` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`requested_ids_json` LONGTEXT NULL,
|
||||
`requested_limit` INT NOT NULL DEFAULT 500,
|
||||
`request_hash` CHAR(64) NOT NULL,
|
||||
`scope_hall_ids_json` LONGTEXT NOT NULL,
|
||||
`processed` INT NOT NULL DEFAULT 0,
|
||||
`total` INT NOT NULL DEFAULT 0,
|
||||
`summary_json` LONGTEXT NULL,
|
||||
`warning` TEXT NULL,
|
||||
`error` TEXT NULL,
|
||||
`lease_token` CHAR(36) NULL,
|
||||
`lease_expires_at` DATETIME NULL,
|
||||
`attempt_count` INT NOT NULL DEFAULT 0,
|
||||
`max_attempts` INT NOT NULL DEFAULT 3,
|
||||
`next_attempt_at` DATETIME NULL,
|
||||
`created_by` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`started_at` DATETIME NULL,
|
||||
`finished_at` DATETIME NULL,
|
||||
`active_execute_slot` TINYINT GENERATED ALWAYS AS (
|
||||
CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END
|
||||
) STORED,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
|
||||
UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`),
|
||||
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_timeline', "VARCHAR(16) NOT NULL DEFAULT 'standard' AFTER scope_hall_ids_json");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_batch_size', 'INT NOT NULL DEFAULT 150 AFTER ai_timeline');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_max_cost_usd', 'DECIMAL(12,4) NULL AFTER ai_batch_size');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 0.5000 AFTER ai_max_cost_usd');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 2.0000 AFTER ai_input_usd_per_1m_usd');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_requests', 'INT NOT NULL DEFAULT 0 AFTER total');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_cache_hits', 'INT NOT NULL DEFAULT 0 AFTER ai_requests');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_cache_hits');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_input_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_total_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_output_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_estimated_cost_usd', 'DECIMAL(14,6) NOT NULL DEFAULT 0.000000 AFTER ai_total_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_budget_exhausted', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER ai_estimated_cost_usd');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
||||
self::addColumnIfMissing(
|
||||
$db,
|
||||
'xlvask_autopilot_runs',
|
||||
'active_execute_slot',
|
||||
"TINYINT GENERATED ALWAYS AS (CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END) STORED AFTER finished_at"
|
||||
);
|
||||
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_active_execute_run')) {
|
||||
if ($db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`)') === false) {
|
||||
throw new \RuntimeException('The unique active XL Vask execute-run index could not be created.');
|
||||
}
|
||||
}
|
||||
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_autopilot_run_idempotency')
|
||||
&& $db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') === false) {
|
||||
throw new \RuntimeException('The unique XL Vask run idempotency index could not be created.');
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` BIGINT NOT NULL,
|
||||
`usage_log_id` INT NULL,
|
||||
`wash_id` VARCHAR(128) NULL,
|
||||
`import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged',
|
||||
`resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review',
|
||||
`certainty` VARCHAR(16) NOT NULL DEFAULT 'none',
|
||||
`planned_action` VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
`source_hash` CHAR(64) NULL,
|
||||
`expected_version` INT NULL,
|
||||
`result_json` LONGTEXT NULL,
|
||||
`error` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`),
|
||||
KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` BIGINT NULL,
|
||||
`usage_log_id` INT NULL,
|
||||
`wash_id` VARCHAR(128) NULL,
|
||||
`event_type` VARCHAR(48) NOT NULL,
|
||||
`action` VARCHAR(32) NULL,
|
||||
`policy_version` VARCHAR(64) NOT NULL,
|
||||
`input_hash` CHAR(64) NULL,
|
||||
`source_revision` VARCHAR(128) NULL,
|
||||
`expected_version` INT NULL,
|
||||
`before_json` LONGTEXT NULL,
|
||||
`after_json` LONGTEXT NULL,
|
||||
`evidence_json` LONGTEXT NULL,
|
||||
`actor_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`),
|
||||
KEY `idx_xlvask_audit_run` (`run_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`policy_version` VARCHAR(64) NOT NULL,
|
||||
`segment_key` VARCHAR(191) NOT NULL,
|
||||
`precision_value` DECIMAL(7,6) NOT NULL,
|
||||
`wilson_lower_bound` DECIMAL(7,6) NOT NULL,
|
||||
`holdout_examples` INT NOT NULL,
|
||||
`segment_examples` INT NOT NULL,
|
||||
`contradictions` INT NOT NULL DEFAULT 0,
|
||||
`calibrated_probability` DECIMAL(7,6) NOT NULL,
|
||||
`artifact_hash` CHAR(64) NOT NULL,
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`backtest_json` LONGTEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`activated_by` INT NULL,
|
||||
`activated_at` DATETIME NULL,
|
||||
`invalidated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
|
||||
KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'automation_identity_hash', 'CHAR(64) NULL AFTER segment_key');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'invalidated_at', 'DATETIME NULL AFTER activated_at');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`suggestion_id` INT NOT NULL,
|
||||
`outcome` VARCHAR(16) NOT NULL,
|
||||
`adjudication_outcome` VARCHAR(32) NULL,
|
||||
`adjudicated_by` INT NOT NULL,
|
||||
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`),
|
||||
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
|
||||
// The nullable legacy id supports an idempotent, non-destructive backfill.
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`suggestion_id` INT NOT NULL,
|
||||
`outcome` VARCHAR(16) NOT NULL,
|
||||
`adjudicated_by` INT NOT NULL,
|
||||
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`legacy_label_id` BIGINT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`),
|
||||
KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`),
|
||||
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing(
|
||||
$db,
|
||||
'xlvask_automation_calibration_label_events',
|
||||
'adjudication_outcome',
|
||||
'VARCHAR(32) NULL AFTER outcome'
|
||||
);
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
|
||||
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
|
||||
SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id
|
||||
FROM xlvask_automation_calibration_labels"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`selection_hash` CHAR(64) NOT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`payload_json` LONGTEXT NOT NULL,
|
||||
`created_by` INT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`applied_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_state` (
|
||||
`id` TINYINT NOT NULL,
|
||||
`policy_version` VARCHAR(64) NOT NULL,
|
||||
`planner_identity_hash` CHAR(64) NOT NULL,
|
||||
`stage` VARCHAR(32) NOT NULL DEFAULT 'off',
|
||||
`halted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`attach_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`create_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`halt_reason` TEXT NULL,
|
||||
`attach_halt_reason` TEXT NULL,
|
||||
`create_halt_reason` TEXT NULL,
|
||||
`halted_at` DATETIME NULL,
|
||||
`halted_by` INT NULL,
|
||||
`attach_activated_at` DATETIME NULL,
|
||||
`attach_activated_by` INT NULL,
|
||||
`create_activated_at` DATETIME NULL,
|
||||
`create_activated_by` INT NULL,
|
||||
`expected_version` INT NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'stage', "VARCHAR(32) NOT NULL DEFAULT 'off' AFTER planner_identity_hash");
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'attach_halt_reason', 'TEXT NULL AFTER halt_reason');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'create_halt_reason', 'TEXT NULL AFTER attach_halt_reason');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_previews` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`selection_hash` CHAR(64) NOT NULL,
|
||||
`requested_transition` VARCHAR(32) NOT NULL,
|
||||
`payload_json` LONGTEXT NOT NULL,
|
||||
`created_by` INT NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`applied_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_policy_preview_expiry` (`expires_at`, `applied_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_events` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`event_type` VARCHAR(48) NOT NULL,
|
||||
`actor_id` INT NOT NULL,
|
||||
`details_json` LONGTEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_policy_event_time` (`created_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_action_events` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`suggestion_id` INT NOT NULL,
|
||||
`run_id` BIGINT NULL,
|
||||
`hall_id` VARCHAR(191) NOT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`source` VARCHAR(32) NOT NULL,
|
||||
`policy_version` VARCHAR(64) NOT NULL,
|
||||
`planner_identity_hash` CHAR(64) NOT NULL,
|
||||
`review_outcome` VARCHAR(32) NULL,
|
||||
`reviewed_by` INT NULL,
|
||||
`reviewed_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`),
|
||||
KEY `idx_xlvask_action_budget` (`action`, `created_at`),
|
||||
KEY `idx_xlvask_action_hall_budget` (`hall_id`, `action`, `created_at`),
|
||||
KEY `idx_xlvask_action_soak` (`source`, `action`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'hall_id', "VARCHAR(191) NOT NULL DEFAULT '' AFTER run_id");
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'review_outcome', 'VARCHAR(32) NULL AFTER planner_identity_hash');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_by', 'INT NULL AFTER review_outcome');
|
||||
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_at', 'DATETIME NULL AFTER reviewed_by');
|
||||
if (!self::indexExists($db, 'xlvask_automation_action_events', 'uniq_xlvask_action_event_suggestion')
|
||||
&& $db->query('ALTER TABLE `xlvask_automation_action_events` ADD UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`)') === false) {
|
||||
throw new \RuntimeException('The unique XL Vask action-event suggestion index could not be created.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function washIdUniquenessReady(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit activation migration. Never call this from constructors, GETs, or normal runs.
|
||||
* Returns false without modifying conflicting records when duplicate wash IDs exist.
|
||||
*/
|
||||
public static function applyWashIdUniquenessMigration(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$duplicates = $db->query(
|
||||
"SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders`
|
||||
WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> ''
|
||||
GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1"
|
||||
);
|
||||
if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) {
|
||||
if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) {
|
||||
$db->query(
|
||||
"ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128)
|
||||
GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED"
|
||||
);
|
||||
}
|
||||
$db->query(
|
||||
"ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)"
|
||||
);
|
||||
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
||||
{
|
||||
if (!self::columnExists($db, $table, $column)) {
|
||||
if ($db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}") === false) {
|
||||
throw new \RuntimeException("The required XL Vask column {$table}.{$column} could not be created.");
|
||||
}
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
|
||||
private static function activeExecuteRunConflicts(object $db): array
|
||||
{
|
||||
if (!self::tableExists($db, 'xlvask_autopilot_runs')
|
||||
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'mode')
|
||||
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'status')) {
|
||||
return [];
|
||||
}
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) total FROM xlvask_autopilot_runs
|
||||
WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')"
|
||||
);
|
||||
if ($result === false || !is_object($result)) {
|
||||
return ['active_execute_preflight_unavailable'];
|
||||
}
|
||||
$row = $db->fetch_assoc($result);
|
||||
$count = (int)($row['total'] ?? 0);
|
||||
return $count > 1 ? ['multiple_active_execute_runs:' . $count] : [];
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
@@ -675,18 +138,6 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
if (!self::tableExists($db, $table)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
||||
return $result !== false && is_object($result) && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
|
||||
@@ -18,9 +18,6 @@ try {
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||
) . PHP_EOL;
|
||||
} catch (\Throwable $e) {
|
||||
// The wrapper cron entry may swallow this throw, so also emit a
|
||||
// container-log breadcrumb before re-raising.
|
||||
error_log('[cron-backfill-economic-v2-history] runBestEffortBackfill failed: ' . $e->getMessage());
|
||||
echo json_encode(
|
||||
[
|
||||
'success' => false,
|
||||
|
||||
@@ -18,11 +18,4 @@ if (!defined('WD')) {
|
||||
$bookings_o = new bookings_o();
|
||||
|
||||
// Check if any bookings from yesterday haven't been fulfilled
|
||||
try {
|
||||
$bookings_o->checkUnfulfilledBookings();
|
||||
} catch (Exception $e) {
|
||||
// This script is invoked directly in the "node cron" container, so any
|
||||
// failure here would otherwise abort the whole script with no breadcrumb.
|
||||
error_log('[cron-check-unfulfilled-bookings] checkUnfulfilledBookings failed: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
$bookings_o->checkUnfulfilledBookings();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user