Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb95a5375 | ||
|
|
7ac90f70c9 | ||
|
|
52d43fc16c | ||
|
|
d2528c5ed7 | ||
|
|
3e39a50a4f | ||
|
|
18dc8e6e9c | ||
|
|
4b08453ee2 | ||
|
|
ae4b7aef07 | ||
|
|
ea9bdbe12c | ||
|
|
b16b2cfe44 | ||
|
|
339b6eb7a5 | ||
|
|
935b2d58ce | ||
|
|
4c6b60c9f2 | ||
|
|
18092b271e | ||
|
|
76ad696691 | ||
|
|
80dca6b5f0 | ||
|
|
7c4acc636c | ||
|
|
34df80530c | ||
|
|
3d0a8eeae7 | ||
|
|
60222a7d91 | ||
|
|
78b11d0b79 | ||
|
|
55ddabb0ee | ||
|
|
16048e2ce3 | ||
|
|
cdf8541e78 | ||
|
|
51e5c2ed01 | ||
|
|
0dea2f0b76 | ||
|
|
22ec6696b5 | ||
|
|
a71194d46e | ||
|
|
5441fea665 | ||
|
|
02a4665bc7 | ||
|
|
3e683c8559 | ||
|
|
d81634033e | ||
|
|
58d5e177a9 | ||
|
|
82a3684f05 | ||
|
|
d850075397 | ||
|
|
43df3e4dca | ||
|
|
7174e3be6c | ||
|
|
2ef0f78541 | ||
|
|
801c8e1f6d | ||
|
|
3e89085296 | ||
|
|
2cf2538525 | ||
|
|
6475f817c7 | ||
|
|
b107ba649c | ||
|
|
0aaf32efa4 | ||
|
|
8735bae8d5 | ||
|
|
bd2ff1be9a | ||
|
|
59107a6bb2 | ||
|
|
622fe59f5c | ||
|
|
23fc410d25 | ||
|
|
db9f589bf7 | ||
|
|
ab6c3ba5b6 | ||
|
|
40b104abed | ||
|
|
842b06c93f | ||
|
|
1f03b46564 | ||
|
|
6d888a455d | ||
|
|
0b304a2203 | ||
|
|
068f9e254f | ||
|
|
f37feef1e6 | ||
|
|
c795df4aad | ||
|
|
1e0e051775 | ||
|
|
4587bdfb06 | ||
|
|
a442e70744 | ||
|
|
1b161f1b96 | ||
|
|
448e0e50c2 | ||
|
|
710baad28e | ||
|
|
42ddce84bc | ||
|
|
da0113e3ed | ||
|
|
3c13892366 | ||
|
|
30d860fdef | ||
|
|
d3e4798b11 | ||
|
|
8d8f0eccce | ||
|
|
6e24718c1f | ||
|
|
9b481e0957 | ||
|
|
0060fb45ca | ||
|
|
34cf804d75 | ||
|
|
677d4700b0 | ||
|
|
abde54c898 | ||
|
|
b177347bf5 | ||
|
|
32a5b99204 | ||
|
|
2a6a86c9c3 | ||
|
|
6566027746 | ||
|
|
511605b619 | ||
|
|
c2abf17cd7 | ||
|
|
fefe18a719 | ||
|
|
9b2d5d5291 | ||
|
|
e1fb79d9b6 | ||
|
|
879dfcf79a | ||
|
|
0feb705059 | ||
|
|
69b3bf83c4 | ||
|
|
5bac316e4b | ||
|
|
233133365d | ||
|
|
026492c3bd | ||
|
|
403f93e62c | ||
|
|
582edd3e6c | ||
|
|
a4fafaf7fb | ||
|
|
327e9cf817 | ||
|
|
012e5366ba | ||
|
|
fa1ade555f | ||
|
|
7a1c444df0 | ||
|
|
6a00f023b1 | ||
|
|
8aefbd8fb3 | ||
|
|
a7181a4ab2 | ||
|
|
fc6c76ad1b | ||
|
|
6a694f92cc | ||
|
|
b7a2dc04d7 | ||
|
|
870b88e707 | ||
|
|
e14cddc1fb | ||
|
|
31887fa8c9 | ||
|
|
eac83b18a0 | ||
|
|
3817a37021 | ||
|
|
940a3e5e9b | ||
|
|
3221223865 | ||
|
|
b51006d9d1 | ||
|
|
6b7592921d | ||
|
|
f26a427510 | ||
|
|
6de747252f | ||
|
|
ff225ff5e7 | ||
|
|
dcef993f12 | ||
|
|
23aca449f7 | ||
|
|
31b5ba136a | ||
|
|
f0b5479f30 | ||
|
|
b77efc538a | ||
|
|
10d1eb5bac | ||
|
|
084435e9b8 | ||
|
|
172a21c517 | ||
|
|
c24428e4c7 | ||
|
|
bf1d6a583e | ||
|
|
08ac16e665 | ||
|
|
79185a3c76 | ||
|
|
3a730e3507 | ||
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 | ||
|
|
7ac5c5585b | ||
|
|
8544ce0a18 | ||
|
|
614715822f | ||
|
|
1da02e2486 | ||
|
|
742b15116d | ||
|
|
e0ae74bdc2 | ||
|
|
08dc803b3e | ||
|
|
248a901f24 | ||
|
|
8bbdf9daf5 | ||
|
|
c089186046 | ||
|
|
2ae1fc3fcf | ||
|
|
d9eacf6f84 | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
1cda2a81aa | ||
|
|
8e46ce1b04 | ||
|
|
9f797bf6b8 | ||
|
|
d345db927f | ||
|
|
11c2a1b72e | ||
|
|
eca7a81f9d | ||
|
|
62f2c80dda | ||
|
|
6f3d7e0f7d | ||
|
|
430c90cbca | ||
|
|
f02dfd8c9c | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
db1b9a2c96 | ||
|
|
38814545c4 | ||
|
|
94c3654240 | ||
|
|
9fa249cc11 | ||
|
|
215c8d0fbb | ||
|
|
84dec4c0a2 | ||
|
|
d47ea1d659 | ||
|
|
3f41eebdf6 | ||
|
|
64e0b2444b | ||
|
|
18fede78f8 | ||
|
|
243d68ab59 | ||
|
|
62c1393f62 | ||
|
|
f0a8299133 | ||
|
|
e36f6da926 | ||
|
|
4a8c2a9fd9 | ||
|
|
248b2e4eca | ||
|
|
1d1ebd2176 | ||
|
|
4252f9a42b | ||
|
|
4fdeedab45 | ||
|
|
866a5be126 | ||
|
|
11d39af934 | ||
|
|
f706531534 | ||
|
|
a839eac4c1 | ||
|
|
581d28e9ce | ||
|
|
2ba39b8174 | ||
|
|
6af55a44c9 | ||
|
|
24badc39d7 | ||
|
|
f5e0baaab6 | ||
|
|
178c84ba60 | ||
|
|
713d40a876 | ||
|
|
9db1964038 | ||
|
|
1ca42055b0 | ||
|
|
c04bda7368 | ||
|
|
1f47843699 | ||
|
|
dca738db82 | ||
|
|
57f364ad0f | ||
|
|
a826153bb5 | ||
|
|
72bd22a707 | ||
|
|
cc73d80dbc | ||
|
|
f0a5b15442 | ||
|
|
eefe5630f4 | ||
|
|
b0ea771e6a | ||
|
|
eb21405a3d | ||
|
|
8ea10ef808 | ||
|
|
ac7da807bd | ||
|
|
3eafc597c6 | ||
|
|
f7a4126718 | ||
|
|
c6cf953ede | ||
|
|
d902202fe9 | ||
|
|
acc80920f9 | ||
|
|
53246af629 | ||
|
|
bd87e94472 | ||
|
|
7ccbb68ffa | ||
|
|
4a7fc7c534 | ||
|
|
f4343ae114 | ||
|
|
0da02dfeb5 | ||
|
|
5f13242cfa | ||
|
|
b492292642 | ||
|
|
ce8ba88b16 | ||
|
|
02b6df5e3b | ||
|
|
6cc4f2759d | ||
|
|
3ea92be722 | ||
|
|
42f8ae0c47 | ||
|
|
148b575767 | ||
|
|
605efacece |
@@ -0,0 +1,56 @@
|
||||
# Default branch protection
|
||||
|
||||
`master` is changed through pull requests. Do not push or publish directly to
|
||||
the default branch, including through automation or the Git Data API.
|
||||
|
||||
## Normal publishing flow
|
||||
|
||||
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
|
||||
2. Commit and push only the intended changes.
|
||||
3. Open a pull request targeting `master`.
|
||||
4. Wait for the `Required CI` check. If `master` moves, update the branch and
|
||||
wait for the strict check to rerun.
|
||||
5. Resolve every review conversation and squash-merge the pull request.
|
||||
6. Confirm the post-merge `Release Manager gate` completes on `master`.
|
||||
|
||||
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
|
||||
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
|
||||
the Release Manager gate is intentionally post-merge.
|
||||
|
||||
## Desired ruleset
|
||||
|
||||
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
|
||||
is the importable final desired-state repository-ruleset request body. For the
|
||||
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
|
||||
the normalized ruleset and verify a green preparation PR and post-merge run,
|
||||
then PUT the exact committed file to activate it.
|
||||
|
||||
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
|
||||
approvals, conversation resolution, strict `Required CI` from GitHub Actions
|
||||
integration `15368`, squash-only linear history, and blocks deletion and force
|
||||
pushes. Repository administrators receive pull-request-only bypass; they do not
|
||||
receive a standing direct-push bypass.
|
||||
|
||||
When the ruleset is activated, align repository settings at the same time:
|
||||
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. Open a pull request and describe the incident, risk, and reason for bypass.
|
||||
2. Have a repository administrator use the pull-request-only bypass.
|
||||
3. Monitor `Required CI` and the post-merge Release Manager workflow.
|
||||
4. Open a follow-up pull request for any deferred validation or remediation.
|
||||
|
||||
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
|
||||
emergency bypasses must remain visible in GitHub's audit trail.
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
ports:
|
||||
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
||||
labels:
|
||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||
@@ -80,3 +82,9 @@ services:
|
||||
|
||||
volumes:
|
||||
ci_php_app:
|
||||
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "Protect default branch",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "pull_request"
|
||||
}
|
||||
],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"~DEFAULT_BRANCH"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"type": "deletion"
|
||||
},
|
||||
{
|
||||
"type": "non_fast_forward"
|
||||
},
|
||||
{
|
||||
"type": "required_linear_history"
|
||||
},
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"allowed_merge_methods": [
|
||||
"squash"
|
||||
],
|
||||
"dismiss_stale_reviews_on_push": false,
|
||||
"require_code_owner_review": false,
|
||||
"require_last_push_approval": false,
|
||||
"required_approving_review_count": 0,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"do_not_enforce_on_create": false,
|
||||
"required_status_checks": [
|
||||
{
|
||||
"context": "Required CI",
|
||||
"integration_id": 15368
|
||||
}
|
||||
],
|
||||
"strict_required_status_checks_policy": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +1,74 @@
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches: # Specify your branches here
|
||||
- main # The 'main' branch
|
||||
- 'releases/*' # The release branches
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- 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
|
||||
- name: Require Qodana Cloud token
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${QODANA_TOKEN:-}" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Qodana Scan'
|
||||
if: ${{ steps.qodana-token.outputs.present == 'true' }}
|
||||
uses: JetBrains/qodana-action@v2026.1
|
||||
- name: Check out the analyzed commit
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
pr-mode: false
|
||||
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
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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
|
||||
# Install and start the cron-worker systemd service (long-running scheduler)
|
||||
if [ -f services/nginx/app/resources/cron-worker.service ]; then
|
||||
sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable cron-worker || true
|
||||
sudo systemctl restart cron-worker || true
|
||||
echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)"
|
||||
fi
|
||||
echo "Deploy complete: $(git rev-parse --short HEAD)"
|
||||
'
|
||||
|
||||
- name: Pre-deploy schema check (run all *_schema_bootstrap)
|
||||
id: pre_schema
|
||||
run: |
|
||||
echo "Running schema bootstraps against the live database…"
|
||||
# Idempotent — adds missing columns, never drops anything.
|
||||
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
|
||||
# production failure mode (TRU-77) where migrations were
|
||||
# merged to master but never applied to the live DB.
|
||||
php scripts/run-schema-bootstraps.php
|
||||
echo "Schema bootstraps complete."
|
||||
|
||||
- name: Alert Slack if schema-check fails (pre-deploy)
|
||||
if: failure()
|
||||
run: |
|
||||
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
|
||||
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
|
||||
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json; charset=utf-8" \
|
||||
https://slack.com/api/chat.postMessage \
|
||||
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
|
||||
|
||||
- name: Smoke test
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh
|
||||
# Also hit the new admin schema-check endpoint to verify
|
||||
# no required columns are missing.
|
||||
echo "::group::Schema health check"
|
||||
php scripts/schema-health-check.php | tee /tmp/schema-report.json
|
||||
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
|
||||
echo "::error::Schema health check FAILED — missing columns:"
|
||||
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
|
||||
exit 1
|
||||
fi
|
||||
echo "Schema health check OK."
|
||||
|
||||
- name: Auto-rollback on smoke failure
|
||||
if: steps.smoke.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
set -e
|
||||
cd /opt/${{ github.event.repository.name }}
|
||||
git reset --hard ${{ steps.pre.outputs.pre_sha }}
|
||||
if [ -f composer.json ]; then
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
sudo systemctl reload php8.2-fpm || true
|
||||
fi
|
||||
if [ -f package.json ]; then
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
sudo systemctl reload nginx || true
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Post Slack status
|
||||
if: always()
|
||||
uses: slackapi/slack-github-action@v1.27.0
|
||||
with:
|
||||
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
|
||||
payload: |
|
||||
{
|
||||
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.repository }}@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke test passed' }}"
|
||||
}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
|
||||
- name: Update Linear issue
|
||||
if: success() && steps.deploy.outcome == 'success'
|
||||
run: |
|
||||
# Find Linear issues in this commit's history and post a comment
|
||||
# (uses GitHub's auto-link: if PR body contains "TRU-123" it auto-links)
|
||||
# We skip this here; the OpenClaw cron `f26dfd83` handles Linear updates.
|
||||
echo "Deploy notification will be picked up by OpenClaw cron."
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Verify e-conomic Live
|
||||
|
||||
# Live verification of e-conomic export sanitization.
|
||||
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
|
||||
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
customer_number:
|
||||
description: 'e-conomic customer number to test against'
|
||||
required: false
|
||||
default: '12345679'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (skip actual API calls, just verify env)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
schedule:
|
||||
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: live-verify-economic
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Live verify e-conomic draft flow
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
|
||||
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
|
||||
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: curl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Dry-run mode (verify env only)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dry-run mode: checking environment..."
|
||||
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
# Mask secrets in logs
|
||||
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
|
||||
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
|
||||
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
|
||||
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
|
||||
echo "All env vars present. Re-run with dry_run=false to do a live test."
|
||||
|
||||
- name: Run live verification (creates and cleans up a real draft)
|
||||
if: ${{ github.event.inputs.dry_run == 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /workspace/copenhagentruckwash/api
|
||||
# Use the script that's checked in
|
||||
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
|
||||
if [ -f scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
# Fallback: use the script from /workspace (where we keep platform scripts)
|
||||
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 /workspace/scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
echo "::error::Live verification script not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload verification logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: live-verify-logs
|
||||
path: |
|
||||
/tmp/verify-economic-*.log
|
||||
.tmp/verify-economic-*.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
|
||||
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
|
||||
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(cat <<EOF
|
||||
{
|
||||
"channel": "$AI_DAILY_CHANNEL",
|
||||
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
+146
-57
@@ -3,25 +3,49 @@ name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
# Docker jobs use disposable workspaces so root-owned container artifacts cannot poison later checkouts.
|
||||
runs-on: ubuntu-24.04
|
||||
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@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -35,7 +59,7 @@ jobs:
|
||||
- name: Upload PHP suite logs
|
||||
if: ${{ failure() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: php-${{ matrix.suite }}-logs
|
||||
path: .tmp/ci-logs/${{ matrix.suite }}
|
||||
@@ -44,18 +68,18 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-agent/package-lock.json
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
@@ -91,11 +115,23 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
}
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
@@ -109,11 +145,9 @@ jobs:
|
||||
docker compose -f docker-compose.example.yml config > /dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-broker/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: services/edge-broker
|
||||
@@ -125,34 +159,88 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
TRAEFIK_WEB_PORT: "18080"
|
||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||
TRAEFIK_METRICS_PORT: "19100"
|
||||
EDGE_BROKER_CI_PORT: "14300"
|
||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
}
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find_free_port() {
|
||||
start="$1"
|
||||
end="$2"
|
||||
port="$start"
|
||||
while [ "$port" -le "$end" ]; do
|
||||
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
|
||||
echo "No free port in range ${start}-${end}." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
base=$((20000 + (GITHUB_RUN_ID % 20000)))
|
||||
web_port="$(find_free_port "$base" "$((base + 2000))")"
|
||||
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
|
||||
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
|
||||
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
|
||||
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
|
||||
|
||||
{
|
||||
echo "TRAEFIK_WEB_PORT=${web_port}"
|
||||
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
|
||||
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
|
||||
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
|
||||
echo "EDGE_BROKER_CI_PORT=${broker_port}"
|
||||
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
|
||||
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
|
||||
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
@@ -161,7 +249,7 @@ jobs:
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: |
|
||||
@@ -246,44 +334,51 @@ jobs:
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
env:
|
||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
required-ci:
|
||||
name: Required CI
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ always() }}
|
||||
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
env:
|
||||
PHP_RESULT: ${{ needs.php.result }}
|
||||
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
|
||||
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
|
||||
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for dependency in \
|
||||
"php=${PHP_RESULT}" \
|
||||
"edge-agent=${EDGE_AGENT_RESULT}" \
|
||||
"edge-broker=${EDGE_BROKER_RESULT}" \
|
||||
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
|
||||
do
|
||||
name="${dependency%%=*}"
|
||||
result="${dependency#*=}"
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
@@ -311,12 +406,6 @@ 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
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@
|
||||
/docker-compose.yml
|
||||
/services/nginx/app/vendor/
|
||||
/services/nginx/app/modules/washcertificates/vendor/
|
||||
/services/nginx/app/.phpunit.cache/
|
||||
/services/nginx/letsencrypt/
|
||||
*.pem
|
||||
*.log.gz
|
||||
@@ -13,4 +14,7 @@
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/.env_old_2
|
||||
/.openclaw/
|
||||
/services/nginx/app/build/phpstan/
|
||||
|
||||
@@ -24,6 +24,7 @@ RUN set -eux; \
|
||||
libzip-dev \
|
||||
mariadb-client \
|
||||
nginx \
|
||||
openssl \
|
||||
pkg-config \
|
||||
redis-tools \
|
||||
unzip \
|
||||
@@ -46,6 +47,8 @@ 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
|
||||
@@ -61,6 +64,8 @@ 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
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Backend API for Copenhagen Truck Wash services.
|
||||
|
||||
Changes are published from a scoped feature branch through a pull request to
|
||||
`master`; direct default-branch pushes are not part of the release workflow.
|
||||
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
|
||||
and emergency procedure.
|
||||
|
||||
## Architecture & Stack
|
||||
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
||||
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ $MINIO = [
|
||||
'access_key' => '', // Minio access
|
||||
'secret_key' => '' // Minio secret key
|
||||
];
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Set through SLACK_DEFAULT_WEBHOOK; never commit a production webhook URL.
|
||||
$REDIS_CONFIG = [
|
||||
'host' => '', // Redis host (IP address)
|
||||
'user' => '', // Redis user
|
||||
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
- redis
|
||||
- mysql
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env.example
|
||||
environment:
|
||||
|
||||
@@ -367,7 +367,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -425,7 +425,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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
|
||||
@@ -0,0 +1,262 @@
|
||||
# E-conomic Export Field Audit (TRU-193)
|
||||
|
||||
**Status:** Complete
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** All user-input fields that flow into e-conomic API payloads from
|
||||
the `copenhagentruckwash/api` backend.
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| User-input fields audited | 17 |
|
||||
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
|
||||
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
|
||||
| Fields that are controlled input (no sanitization needed) | 4 |
|
||||
| Fields not present in any e-conomic export path (out of scope) | 3 |
|
||||
|
||||
All user-input fields flowing to e-conomic are now either sanitized via
|
||||
`economic_export_sanitizer` or verified to be controlled input.
|
||||
|
||||
## Sanitizer methods used
|
||||
|
||||
| Method | Purpose | Length cap |
|
||||
|--------|---------|------------|
|
||||
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
|
||||
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
|
||||
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
|
||||
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
|
||||
|
||||
Rules applied:
|
||||
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
|
||||
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
|
||||
- Tab + newline characters collapse to a single space
|
||||
- Whitespace normalized and trimmed
|
||||
- Length capped with `...` suffix if too long
|
||||
|
||||
## Audit by field
|
||||
|
||||
### 1. `order.po` (purchase order)
|
||||
- **Source:** `orders_o::po` (user input)
|
||||
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 2. `order.reference`
|
||||
- **Source:** `orders_o::reference` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
|
||||
|
||||
### 3. `order.notes`
|
||||
- **Source:** `orders_o::notes` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
|
||||
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
|
||||
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
|
||||
- **Sensitive to:** `/`, special chars, length (capped at 50)
|
||||
|
||||
### 5. `department.name`
|
||||
- **Source:** `departments_o::getDepartmentName()` (admin input)
|
||||
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
|
||||
|
||||
### 6. `order.created_at` (formatted date)
|
||||
- **Source:** `orders_o::created_at` (server-generated timestamp)
|
||||
- **Flows to:** Transaction header line date prefix
|
||||
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
|
||||
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
|
||||
but the sanitizer does not run on the formatted string — verified by inspection
|
||||
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
|
||||
|
||||
### 7. `order.id` (integer)
|
||||
- **Source:** Database auto-increment
|
||||
- **Flows to:** Transaction header line `#{id}` suffix
|
||||
- **Status:** ✅ Controlled input — integer
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 8. `order_item.reference`
|
||||
- **Source:** Per-item reference (user input)
|
||||
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 9. `order_item.notes`
|
||||
- **Source:** Per-item notes (user input)
|
||||
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 10. `order_item.product.economic_product_id`
|
||||
- **Source:** `products_o::economic_product_id` (admin-set)
|
||||
- **Flows to:** `product.productNumber` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductNumber()`
|
||||
- **Sensitive to:** Path separators, illegal chars
|
||||
|
||||
### 11. `order_item.product.name`
|
||||
- **Source:** `products_o::name` (admin-set product name)
|
||||
- **Flows to:** `description` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
|
||||
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
|
||||
|
||||
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
|
||||
- **Source:** Numeric fields (calculated or admin-set)
|
||||
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
|
||||
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 13. Currency (`DKK`, `EUR`, etc.)
|
||||
- **Source:** Admin-set on the department / invoice
|
||||
- **Flows to:** `'currency' => $currency` in the invoice payload
|
||||
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.name` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
|
||||
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
|
||||
it), we sanitize defensively in case e-conomic later rejects a value it
|
||||
previously accepted, or in case the API contract changes. Cap of 100 chars
|
||||
matches the e-conomic recipient `name` field limit.
|
||||
|
||||
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.address` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 250)`
|
||||
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
|
||||
countries use `/` in street names), control chars, length
|
||||
- **Rationale:** Same as `recipient.name` — defense in depth.
|
||||
|
||||
### 16. `recipient.zip`, `recipient.city`
|
||||
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
|
||||
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
|
||||
- **Sensitive to (defense in depth):** Special chars, length
|
||||
- **Rationale:** Defense in depth — same as above.
|
||||
|
||||
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getEan()` (e-conomic data)
|
||||
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
|
||||
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
|
||||
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
|
||||
rather than sending an empty string (which e-conomic may reject).
|
||||
|
||||
## Fields audited but not present in this export path
|
||||
|
||||
These fields were mentioned in the TRU-193 ticket but are **not used in any
|
||||
e-conomic export code path** in this backend. Documenting them for
|
||||
completeness:
|
||||
|
||||
| Field | Why not in scope |
|
||||
|-------|------------------|
|
||||
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
|
||||
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
|
||||
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
|
||||
|
||||
## Other controlled inputs (no sanitization needed)
|
||||
|
||||
| Field | Why safe |
|
||||
|-------|----------|
|
||||
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
|
||||
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
|
||||
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
|
||||
| `vatZone.vatZoneNumber` | Integer from e-conomic |
|
||||
| `customer.customerNumber` | Integer from e-conomic |
|
||||
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
|
||||
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
|
||||
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
|
||||
| `TotDiscount` (productNumber for discount line) | Literal string constant |
|
||||
| `'Rabat'` (description for discount line) | Literal string constant |
|
||||
|
||||
## Defense in depth: preflight validation
|
||||
|
||||
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
|
||||
now runs a **preflight validation** before sending to e-conomic. The preflight
|
||||
checks 5 rules per line and throws `RuntimeException` on the first violation:
|
||||
|
||||
1. `description` must be non-empty after `trim()`
|
||||
2. `description` must be ≤ 250 chars
|
||||
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
|
||||
4. `quantity` (if present) must be a positive number
|
||||
5. `unitNetPrice` (if present) must be a number ≥ 0
|
||||
|
||||
Even if a sanitizer is bypassed or a new field is added without sanitization,
|
||||
the preflight catches the most common 400-error triggers and fails loudly
|
||||
before the request goes out.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
|
||||
- Original 31: slash replacement, control chars, tab/newline handling,
|
||||
whitespace collapse, length cap with ellipsis, multibyte safety,
|
||||
null/empty input, integer/float input, product number rules
|
||||
- New 14 (TRU-193): recipient name/address/zip/city length caps,
|
||||
recipient address newlines + slashes, Danish/UK postal formats,
|
||||
Danish special chars (København Ø), ampersand + quotes, CRLF
|
||||
normalization, empty-field handling, EAN digit preservation
|
||||
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
|
||||
- Covers: all 5 preflight rules + the disabled-flag bypass path
|
||||
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
|
||||
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
|
||||
(sanitize calls for name/address/zip/city, preg_replace for EAN,
|
||||
empty-EAN unsets the key)
|
||||
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
|
||||
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
|
||||
preflight catches all 5 rules, mixed text + product flow works
|
||||
|
||||
Total: 94 tests, 171 assertions, all passing.
|
||||
|
||||
## What changed in TRU-193
|
||||
|
||||
1. **Pre-flight validation** added to `economic_invoice_draft.php`
|
||||
(separate atomic commit) — defense in depth.
|
||||
2. **Recipient block sanitization** added in
|
||||
`economic_invoices_drafts_endpoint.php`:
|
||||
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
|
||||
now go through `sanitizeTextLine()` with field-appropriate length caps.
|
||||
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
|
||||
removed from the payload (and `nemHandelType` is not set).
|
||||
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
|
||||
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
|
||||
happened in the calling methods). Catches any new caller that forgets
|
||||
to sanitize.
|
||||
- `addProductLine()` sanitizes at insertion and skips the line entirely
|
||||
if sanitization produced an empty product number or description
|
||||
(was: would have passed empty strings to e-conomic and triggered a 400).
|
||||
4. **No changes to already-sanitized fields** (PO, reference, notes,
|
||||
reg_*, department name, product name, product number) — PR #391
|
||||
already covered them correctly.
|
||||
|
||||
## Refs
|
||||
|
||||
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
|
||||
- TRU-189 through TRU-196 — Related issues covered by PR #391
|
||||
- TRU-194 — Pre-flight validation (separate workstream)
|
||||
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
|
||||
- PR #392 — Pre-flight validation defense in depth
|
||||
@@ -0,0 +1,82 @@
|
||||
# GitHub Secrets for e-conomic Live Verification
|
||||
|
||||
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
| Secret | Description | Where to find it | Required? |
|
||||
|---|---|---|---|
|
||||
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
|
||||
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
|
||||
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
|
||||
|
||||
## Optional Secrets (for Slack notifications)
|
||||
|
||||
| Secret | Description | Required? |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
|
||||
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
|
||||
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
|
||||
|
||||
## How to Configure
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
|
||||
2. Click **"New repository secret"**
|
||||
3. Add each of the required secrets above
|
||||
4. The values are found in your e-conomic account settings
|
||||
|
||||
## How to Run the Live Verification
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
|
||||
2. Click **"Run workflow"**
|
||||
3. Leave `customer_number` as `12345679` (default)
|
||||
4. Set `dry_run` to **`false`** for a real test
|
||||
5. Click **"Run workflow"**
|
||||
6. The workflow will:
|
||||
- Create a draft invoice for customer 12345679
|
||||
- Add 2 test lines (1 with discount, 1 without)
|
||||
- Verify the draft was created correctly
|
||||
- **Automatically delete the draft** to clean up
|
||||
|
||||
## Safety
|
||||
|
||||
- The verification script is **idempotent**: it always cleans up after itself
|
||||
- On any error, it attempts emergency cleanup of any draft it created
|
||||
- The script refuses to run without the required env vars
|
||||
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
|
||||
|
||||
## When It Runs Automatically
|
||||
|
||||
- **Manual trigger only by default**
|
||||
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
|
||||
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
|
||||
|
||||
## Setting Up in Production (api.truckwash.io)
|
||||
|
||||
The same e-conomic credentials are also used by the live API. They're stored in:
|
||||
- The production server's `.env` file (loaded by PHP)
|
||||
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
|
||||
|
||||
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
|
||||
The secret is not configured. Follow the "How to Configure" steps above.
|
||||
|
||||
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
|
||||
Same as above for the secret token.
|
||||
|
||||
### "Draft creation returned HTTP 401"
|
||||
|
||||
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
|
||||
|
||||
### "Draft creation returned HTTP 403"
|
||||
|
||||
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
|
||||
|
||||
### "Customer 12345679 not found"
|
||||
|
||||
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Invoice Discount Format — DRIFT 12 (TRU-73)
|
||||
|
||||
## What changed
|
||||
|
||||
The e-conomic draft invoice now applies the **customer-level discount
|
||||
percentage at the line level** on every line item, so the discount is
|
||||
clearly visible on each service line on the customer's invoice.
|
||||
|
||||
Before this fix, a customer with a global e-conomic discount (e.g. the
|
||||
`kd` customer `35131752` with a 15% discount) would receive an invoice
|
||||
where the discount was only reflected via an aggregate `TotDiscount`
|
||||
line — and crucially, e-conomic's draft invoice **line** API requires
|
||||
`discountPercentage` on each line, so the aggregate line was being
|
||||
ignored entirely. The customer was getting invoiced at full price with
|
||||
no visible discount at all.
|
||||
|
||||
## Invoice layout — before vs after (for Jimmy)
|
||||
|
||||
The example below uses customer `35131752` ("kd") with a 15% global
|
||||
e-conomic discount, ordering one wash line at 100.00 DKK.
|
||||
|
||||
### Before the fix (DRIFT 12 — discount silently dropped)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask 1 × 100,00 DKK 100,00
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat (15%) 0,00 DKK ← never applied
|
||||
Total 100,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The `Rabat` line was never actually created on the e-conomic side
|
||||
because the customer has a per-line discount configured, not an
|
||||
aggregate one. The customer saw 100,00 DKK with no discount displayed.
|
||||
|
||||
### After the fix (TRU-73)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask (15% rabat) 1 × 100,00 DKK 100,00
|
||||
Rabat: -15,00 DKK (15%)
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat 15,00 DKK
|
||||
Total 85,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The 15% discount now appears on the wash line itself (via the
|
||||
`discountPercentage` field that e-conomic renders on each line), and
|
||||
the subtotal correctly reflects the 85,00 DKK total the customer owes.
|
||||
|
||||
## How the fix works
|
||||
|
||||
1. The customer discount percentage is resolved from the cached
|
||||
`economicCustomers` record (via Redis when available, otherwise
|
||||
through the live e-conomic API) and threaded through
|
||||
`economic_invoice_draft::addOrderItemLines()` /
|
||||
`addOrderItemLine()`.
|
||||
2. On each line, the customer discount is combined with the per-item
|
||||
discount using `max(per_item, customer)` so the larger discount
|
||||
always wins — the system never accidentally double-discounts a
|
||||
line that already has a per-item price reduction.
|
||||
3. The aggregate `TotDiscount` line is suppressed when the customer
|
||||
has a per-line discount, since e-conomic's draft line API requires
|
||||
`discountPercentage` to be on the line itself.
|
||||
4. The customer discount is clamped to 0..100 to guard against bad
|
||||
data from the e-conomic API.
|
||||
|
||||
## Code paths
|
||||
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
|
||||
`customer_discount_percentage` argument and combine it with the
|
||||
per-item discount at the line level.
|
||||
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
|
||||
— logs swallowed missing-currency-price errors so silently-missing
|
||||
discounts become visible in the application log.
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
— forwards the customer discount percentage to the draft builder.
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— resolves the customer discount via the Redis cache + e-conomic
|
||||
customer index and passes it to the draft builder.
|
||||
|
||||
## Tests
|
||||
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
|
||||
— new tests covering the customer 35131752 case (15% global discount,
|
||||
applied at line level) plus edge cases: per-item + customer discount
|
||||
combined, clamping to 0..100, zero-discount baseline.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
|
||||
— updated to account for the new parameter and the customer-discount
|
||||
guard on the aggregate `TotDiscount` line.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
|
||||
— updated to thread the new parameter through the batch transfer
|
||||
pipeline.
|
||||
@@ -0,0 +1,335 @@
|
||||
# E-conomic Invoice Template Audit (TRU-197)
|
||||
|
||||
**Status:** Complete (no live call — credentials unavailable in this environment)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Audit of the e-conomic invoice layouts available in the
|
||||
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
|
||||
the two-layout strategy (one for invoices **with** itemized discounts, one for
|
||||
invoices **without**).
|
||||
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
|
||||
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — Recommendation
|
||||
|
||||
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|
||||
|---------|---------------------|---------------------|---------------|
|
||||
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
|
||||
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
|
||||
|
||||
The two layout numbers above are **placeholders** to be confirmed by the
|
||||
account admin in e-conomic. They are written into the runtime config variables
|
||||
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
|
||||
section below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a 2-layout strategy is needed
|
||||
|
||||
The `copenhagentruckwash/api` backend already has plumbing for two invoice
|
||||
layouts (see §4 below). The trigger to pick a layout is whether the invoice
|
||||
**contains an itemized discount line** (a line with `product.productNumber =
|
||||
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
|
||||
`Rabat` aggregator in `economic_invoice_draft`).
|
||||
|
||||
When such a line is present, the system routes the invoice through
|
||||
`invoiceDiscountLayoutNumber`; otherwise it falls back to
|
||||
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
|
||||
that match these two intents (clean invoice vs. one that shows discounts
|
||||
itemized).
|
||||
|
||||
---
|
||||
|
||||
## 2. Available e-conomic API for layouts
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```
|
||||
GET https://restapi.e-conomic.com/layouts
|
||||
```
|
||||
|
||||
### 2.2 Auth headers (same as every other e-conomic call)
|
||||
|
||||
```
|
||||
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
|
||||
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 2.3 Response shape
|
||||
|
||||
The endpoint already exists in the codebase at
|
||||
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
|
||||
and is exposed to superusers via
|
||||
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
|
||||
The PHP wrapper returns the raw JSON decoded into a stdClass:
|
||||
|
||||
```json
|
||||
{
|
||||
"collection": [
|
||||
{
|
||||
"layoutNumber": 1,
|
||||
"name": "Standard",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/1"
|
||||
},
|
||||
{
|
||||
"layoutNumber": 12,
|
||||
"name": "Rabat variant",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/12"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The minimal documented fields per layout are:
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|---------|-------------|
|
||||
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
|
||||
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
|
||||
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
|
||||
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
|
||||
|
||||
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
|
||||
> concept in e-conomic is per-customer-group, not global. To find the agreement
|
||||
> default, query `/customers?filter=...` and look at the layout referenced on
|
||||
> each customer group's default. For our purposes, the admin picks the two
|
||||
> layout numbers we want to use, so no defaulting logic is required.
|
||||
|
||||
### 2.4 Example curl (run with real creds)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
|
||||
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
|
||||
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq '.collection[] | {layoutNumber, name, deleted}'
|
||||
```
|
||||
|
||||
### 2.5 Example Python (run with real creds)
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
r = requests.get(
|
||||
"https://restapi.e-conomic.com/layouts",
|
||||
headers={
|
||||
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
|
||||
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for layout in r.json()["collection"]:
|
||||
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Live call — was it made?
|
||||
|
||||
**No.** This audit was run in a sandbox that does not have
|
||||
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
|
||||
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
|
||||
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
|
||||
would have polluted the e-conomic log with a noisy failed request at worst.
|
||||
The two layout numbers used by the test fixtures
|
||||
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
|
||||
**configured** values that need to be **confirmed** by the e-conomic account
|
||||
admin and, if changed, written into the e-conomic module config (see §4.3
|
||||
env-var mapping).
|
||||
|
||||
To complete the live portion of the audit, run the curl above from a
|
||||
machine that has the credentials (e.g. a developer laptop or a CI runner with
|
||||
the secrets mounted). Paste the output into §6 of this doc and commit.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current code state
|
||||
|
||||
### 4.1 Where layouts are read at runtime
|
||||
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` (line 115): returns either
|
||||
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
|
||||
contains a `discountPercentage > 0` product line.
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` (line 673): same logic for collected
|
||||
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
|
||||
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
|
||||
(default = `invoice_layout`).
|
||||
|
||||
### 4.2 Where layouts are configured
|
||||
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
registers the `invoiceLayoutNumber` module config variable (default `1`,
|
||||
required). This is the "no-discount" layout.
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
registers the `invoiceDiscountLayoutNumber` module config variable (default
|
||||
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
|
||||
|
||||
Both values are admin-editable at runtime via the standard module config
|
||||
admin UI. The system status probe also lists them as required:
|
||||
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
|
||||
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
|
||||
`1` / `6`).
|
||||
|
||||
### 4.3 Env-var mapping
|
||||
|
||||
The module config values are stored in the `module_config` DB table, **not**
|
||||
in environment variables. The contract is:
|
||||
|
||||
| Runtime value | Source | Where it's set |
|
||||
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
|
||||
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
|
||||
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
|
||||
|
||||
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
|
||||
e-conomic — they have no relationship to the layout-number config values.
|
||||
|
||||
That said, the task description asks for two env-var-style placeholders.
|
||||
We will add the following **module-config aliases** (constants only, no
|
||||
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
|
||||
operator or a deployment automation can refer to them by name:
|
||||
|
||||
| Module-config constant | Friendly alias env-var-style name | Meaning |
|
||||
|-----------------------------------|--------------------------------------|--------------------|
|
||||
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
|
||||
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
|
||||
|
||||
> If the deployment process is ever updated to read these from env vars
|
||||
> instead of the module-config DB, the constant names in
|
||||
> `economic_layout_selector.php` are the right place to wire that up.
|
||||
|
||||
### 4.4 Existing PRs and related work
|
||||
|
||||
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
|
||||
`economic_export_sanitizer` class and per-field sanitization on the
|
||||
draft invoice lines, recipient block, and references.
|
||||
* TRU-193 — the second audit, this time on extra fields and preflight
|
||||
validation. See `documentation/economic/export-field-audit.md` for the
|
||||
full sanitization audit.
|
||||
* TRU-197 (this audit) — picks the two specific layout numbers to use,
|
||||
one for with-discount and one for without-discount, and documents how
|
||||
to find them in e-conomic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual differences (to be verified)
|
||||
|
||||
Layouts in e-conomic are visually configured in the **Settings → Design and
|
||||
Layouts** UI; the REST API only exposes their names and numbers, not their
|
||||
visual representation. From the existing example invoice
|
||||
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
|
||||
a **booked** invoice with discounts has this structure:
|
||||
|
||||
```
|
||||
lines: [
|
||||
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
|
||||
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
|
||||
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
|
||||
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
|
||||
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
|
||||
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
|
||||
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
|
||||
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
|
||||
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
|
||||
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
|
||||
{ lineNumber: 11, sortKey: 11 }
|
||||
]
|
||||
```
|
||||
|
||||
This invoice was **booked** with `layoutNumber = 12` (per the sample in
|
||||
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
|
||||
it predates the audit and is not necessarily the final answer.
|
||||
|
||||
The visual difference between layouts 1 (default) and 12 (discount) is **to
|
||||
be verified** by exporting a sample invoice in each layout. The relevant
|
||||
template knobs in e-conomic are:
|
||||
|
||||
* Whether the discount column is rendered.
|
||||
* Whether the `Rabat` line is broken out vs. folded into the per-product
|
||||
`discountPercentage`.
|
||||
* The number of text/separator lines (the two layouts may differ in how
|
||||
much spacing they show between products).
|
||||
|
||||
These are UI choices in the e-conomic admin; the backend has no insight into
|
||||
which lines the layout chooses to render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Live-call results — TO BE FILLED IN
|
||||
|
||||
_Paste the output of the curl in §2.4 below, then commit._
|
||||
|
||||
```
|
||||
# layoutNumber name deleted
|
||||
# ------------ ---------------------------- -------
|
||||
# 1 Standard false
|
||||
# 12 Rabat variant false
|
||||
# ...
|
||||
```
|
||||
|
||||
Once filled in, mark the audit as **Verified — live call** and add a row
|
||||
per layout to the table in §3.1 if the layout count is larger than
|
||||
expected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files added in this PR
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `documentation/economic/invoice-template-audit.md` | This document. |
|
||||
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
|
||||
|
||||
The `economic_layout_selector.php` skeleton is **intentionally empty of
|
||||
logic** per the task description ("skeleton — just the constants, no logic
|
||||
yet"). Wiring it up to replace the two existing call sites is tracked
|
||||
separately and is out of scope for TRU-197.
|
||||
|
||||
---
|
||||
|
||||
## 8. What we recommend the e-conomic admin do
|
||||
|
||||
1. Open e-conomic → Settings → Design and Layouts.
|
||||
2. **Duplicate** the current "standard" layout (the one currently set as
|
||||
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
|
||||
3. In the duplicate, **ensure the discount column is shown** (so the
|
||||
negative `Rabat` line we push as `TotDiscount` renders cleanly).
|
||||
4. Note the `layoutNumber` of:
|
||||
* The original (clean) layout → set as `invoiceLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
(admin override, or via the module config UI).
|
||||
* The duplicate (with-discounts) layout → set as
|
||||
`invoiceDiscountLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
|
||||
5. Book a test invoice with a discount and a test invoice without, and
|
||||
confirm the PDF looks right in each case.
|
||||
|
||||
---
|
||||
|
||||
## 9. Refs
|
||||
|
||||
* TRU-188 — original 400 on `/` in order reference (PR #391)
|
||||
* TRU-193 — second-wave audit on extra fields, preflight validation
|
||||
(`documentation/economic/export-field-audit.md`)
|
||||
* PR #391 — initial sanitization fix
|
||||
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
|
||||
— `GET /layouts` wrapper
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` for single draft invoices
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` for collected (batched) invoices
|
||||
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
|
||||
@@ -0,0 +1,274 @@
|
||||
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
|
||||
|
||||
**Status:** Complete (investigation only — no code changes)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
|
||||
an e-conomic draft invoice or sends draft lines, and document whether each
|
||||
path currently picks a layout, which one it picks, and how the planned
|
||||
**with-discounts / without-discounts** two-layout selection should apply.
|
||||
|
||||
**Related work:**
|
||||
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
|
||||
e-conomic layout numbers to use (one for clean invoices, one for invoices
|
||||
that show itemized discounts).
|
||||
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
|
||||
/ sanitization, unrelated to layout selection but consumed by the same code
|
||||
paths.
|
||||
- PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line
|
||||
paths now run their text through.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A draft invoice in this codebase is built in two phases:
|
||||
|
||||
1. **Create the draft envelope** — `POST /invoices/drafts` with a payload
|
||||
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
|
||||
`recipient`, `currency`, `date`, etc. This is the only place where
|
||||
`layout.layoutNumber` is set on the draft.
|
||||
2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an
|
||||
array of product / text / discount lines. Lines are added either one
|
||||
order at a time (single-order draft flow) or in accumulated batches
|
||||
(collected-invoice flow). The layout is **already fixed** at this point
|
||||
and is not re-sent.
|
||||
|
||||
There are therefore only **two** code paths in the entire backend that
|
||||
create the draft envelope and could pick a layout. Both already implement
|
||||
a discount-aware selector that returns either `invoice_layout` (no
|
||||
discounts) or `invoice_discount_layout` (itemized discounts present):
|
||||
|
||||
| Selector function | Used by | File |
|
||||
|---|---|---|
|
||||
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
|
||||
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
|
||||
|
||||
The two selectors are independent implementations of the same idea. They
|
||||
both:
|
||||
|
||||
1. Inspect the lines that will be sent (or the orders that will be added
|
||||
to the draft).
|
||||
2. If any line / order has a non-zero `discountPercentage` (or, in the
|
||||
collected-invoice path, any "billable discount" per
|
||||
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
|
||||
`invoice_discount_layout`.
|
||||
3. Otherwise return `invoice_layout`.
|
||||
4. Throw a `RuntimeException` / `Exception` if the discount layout is
|
||||
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
|
||||
|
||||
The two config variables are defined in:
|
||||
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
— `invoiceLayoutNumber`, `int`, **required** (default `1`).
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
— `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
|
||||
- Both are wired into `classes\economic::$config` via
|
||||
`services/nginx/app/modules/economic/economic_c.php` lines 25–48.
|
||||
|
||||
> **Net result of the audit:** the two-layout selection is already
|
||||
> implemented in both places where a draft envelope is created. There is
|
||||
> **no** code path that creates a draft without going through one of these
|
||||
> two selectors. The migration is therefore a configuration change (set
|
||||
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
|
||||
> change. See §5 *Migration plan* for the small set of files that still
|
||||
> touch the layout topic and may need follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory of code paths
|
||||
|
||||
The table below lists every PHP function in `services/nginx/app/` that
|
||||
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
|
||||
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
|
||||
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
|
||||
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
|
||||
helper) are excluded — they never pick a layout.
|
||||
|
||||
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
|
||||
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
|
||||
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
|
||||
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
|
||||
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
|
||||
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
|
||||
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
|
||||
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
|
||||
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
|
||||
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
|
||||
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
|
||||
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
|
||||
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
|
||||
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
|
||||
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
|
||||
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
|
||||
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
|
||||
| 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
|
||||
**Read-only paths (excluded from the migration list):**
|
||||
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)`
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
|
||||
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
|
||||
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
|
||||
|
||||
**Out of scope (no draft creation):**
|
||||
|
||||
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
|
||||
(read-only aggregations over booked invoices). Never creates a draft.
|
||||
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
|
||||
data class. No HTTP calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state
|
||||
|
||||
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
|
||||
#16 / `createInvoiceDraft`) already have a working discount-aware selector
|
||||
that returns one of two layout numbers from the config store.
|
||||
- The selectors read from the same two config variables
|
||||
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
|
||||
already wired into `economic::$config` and surfaced in the
|
||||
`EconomicConfigEntry` OpenAPI schema.
|
||||
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
|
||||
(see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(...,
|
||||
true, ...)` with `required = true` in the call signature but the
|
||||
constructor's third arg `false` means a null value is allowed; the
|
||||
selectors throw if it is required and ≤ 0).
|
||||
- The selectors are independent code paths. They each inspect lines
|
||||
slightly differently:
|
||||
- The MO selector (`hasDiscountedItemizedLines`) checks
|
||||
`discountPercentage > 0` per line.
|
||||
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
|
||||
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
|
||||
which checks for a `TotDiscount` product (negative net price) on
|
||||
included invoice items.
|
||||
- Both reach the same boolean result: *does this draft need the discount
|
||||
layout?* — so the layout chosen by either selector is consistent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Desired state
|
||||
|
||||
After TRU-197 picks the two layout numbers and the operator configures
|
||||
them in the `economic` module:
|
||||
|
||||
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
|
||||
invoices.
|
||||
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
|
||||
**discount** invoices.
|
||||
|
||||
Then:
|
||||
|
||||
- A single-order draft with no itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
|
||||
- A single-order draft with an itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
|
||||
#4).
|
||||
- A collected-invoice draft with no billable discount goes out with
|
||||
`invoiceLayoutNumber` (path #16 / selector #17).
|
||||
- A collected-invoice draft with a billable discount goes out with
|
||||
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
|
||||
|
||||
No code changes are required to achieve this — only the two config
|
||||
variables need to be set in the `economic` module (and validated by
|
||||
the superuser status probe at `superuser_system_status_service.php:866`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration plan
|
||||
|
||||
Because the selectors already exist, the migration is a **configuration
|
||||
rollout** plus a small handful of defensive tasks. Files to touch:
|
||||
|
||||
### 4.1 Required for rollout
|
||||
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
|
||||
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
|
||||
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
|
||||
constructor signature already allows this to be a non-required variable,
|
||||
but the selectors will throw a `RuntimeException` / `Exception` if the
|
||||
discount layout is required and the value is 0 or null — so the rollout
|
||||
must include setting this var in every environment.)
|
||||
|
||||
### 4.2 Verify-only (no edits expected)
|
||||
|
||||
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
|
||||
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
|
||||
as required keys for the `economic` module probe. Confirm the probe
|
||||
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
|
||||
error when missing (it currently appears in the `required` array, which
|
||||
is the correct behavior).
|
||||
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
|
||||
enum already includes `invoiceDiscountLayoutNumber`. No change.
|
||||
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`**
|
||||
— test fixtures already cover both layout config vars. Confirm values
|
||||
match TRU-197's picks.
|
||||
|
||||
### 4.3 Optional follow-ups (not blocking the rollout)
|
||||
|
||||
- **Defensive logging** in the two selector functions
|
||||
(`economic_invoice_draft_mo::resolveLayoutNumber` and
|
||||
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
|
||||
layout was chosen and why (e.g.
|
||||
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
|
||||
This is useful for post-rollout verification in the e-conomic UI.
|
||||
- **A single, shared selector helper** that both paths use, to avoid
|
||||
drift between the two private selectors. Recommended location:
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
or a new
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
|
||||
Out of scope for the configuration rollout; consider for a follow-up
|
||||
refactor.
|
||||
- **E2E / integration test** that:
|
||||
1. Creates a single-order draft with at least one discounted line and
|
||||
asserts the resulting draft's `layout.layoutNumber` equals
|
||||
`invoiceDiscountLayoutNumber`.
|
||||
2. Creates a single-order draft with no discounted lines and asserts
|
||||
`invoiceLayoutNumber`.
|
||||
3. Creates a collected-invoice draft with at least one
|
||||
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
|
||||
4. Creates a collected-invoice draft with no `TotDiscount` lines and
|
||||
asserts `invoiceLayoutNumber`.
|
||||
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
|
||||
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
|
||||
|
||||
### 4.4 Files that explicitly need NO changes
|
||||
|
||||
- `services/nginx/app/classes/economic_v2_distribution_service.php` —
|
||||
distribution reporting, not a draft creator.
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
|
||||
— booked-invoice data class.
|
||||
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
|
||||
/ `add_environmental_and_oil_fees` paths — they operate on an existing
|
||||
draft whose layout was fixed at create time.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
|
||||
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
|
||||
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
|
||||
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
|
||||
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
|
||||
|
||||
The 2-layout selection is already wired through the backend. The TRU-198
|
||||
investigation confirms that the rollout reduces to setting the two
|
||||
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
|
||||
picks, plus optional defensive logging and an E2E test for verification.
|
||||
@@ -9856,6 +9856,7 @@
|
||||
"Orders"
|
||||
],
|
||||
"summary": "Create Stripe payment intent",
|
||||
"description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.",
|
||||
"operationId": "createStripePaymentIntent",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -9873,9 +9874,6 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# TRU-62 — Customer search / transaction history slow (~10s)
|
||||
|
||||
**Investigation date:** 2026-08-17
|
||||
**Branch:** `feat/TRU-62-perf-customer-search`
|
||||
**Investigator:** automated perf-investigation agent
|
||||
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
|
||||
|
||||
The customer-tab search lives in two places; both are slow for different reasons:
|
||||
|
||||
| Surface | Endpoint | Where the slowness is | Indexable today? |
|
||||
| --- | --- | --- | --- |
|
||||
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
|
||||
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
|
||||
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Root causes (ranked)
|
||||
|
||||
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
|
||||
|
||||
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510–600).
|
||||
|
||||
```php
|
||||
// system_search_service.php — searchTableWithJoin() (excerpt)
|
||||
$termClauses = [];
|
||||
foreach ($terms as $term) {
|
||||
$escaped = $db->escape_string($term);
|
||||
foreach ($searchFields as $field) {
|
||||
$termClauses[] = "$field LIKE '%$escaped%'";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// db_object_t.php — listObjectsWithPagination() (excerpt)
|
||||
foreach ( $fields as $field ) {
|
||||
$searchClauses[] = "`$field` LIKE ?";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
```
|
||||
|
||||
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
|
||||
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
|
||||
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
|
||||
|
||||
**Symptom → data size (estimate).**
|
||||
|
||||
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1k | 100k | ~50ms | ~300ms |
|
||||
| 10k | 1M | ~500ms | ~3s |
|
||||
| 50k+ | 5M+ | ~3–10s ❌ | ~10s+ ❌ |
|
||||
|
||||
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
|
||||
|
||||
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
|
||||
|
||||
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
|
||||
|
||||
```php
|
||||
// system_search_service.php — executeLexicalSearch() (excerpt)
|
||||
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
|
||||
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
|
||||
} else {
|
||||
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
|
||||
}
|
||||
```
|
||||
|
||||
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
|
||||
|
||||
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
|
||||
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
|
||||
|
||||
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
|
||||
|
||||
`services/nginx/app/classes/system_search_service.php` lines 713–820:
|
||||
|
||||
```php
|
||||
$fromClause = 'users u';
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
|
||||
}
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields, // 13 fields
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
```
|
||||
|
||||
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
|
||||
|
||||
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
|
||||
|
||||
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
|
||||
|
||||
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
|
||||
|
||||
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
|
||||
|
||||
### RC6 — `users.display_name` has no index at all
|
||||
|
||||
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
...
|
||||
KEY `idx_users_customer_number` (`customer_number`),
|
||||
KEY `idx_users_group_id` (`group_id`)
|
||||
);
|
||||
```
|
||||
|
||||
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SQL queries involved (verbatim paths)
|
||||
|
||||
### 3.1 Customer search via the unified search endpoint
|
||||
|
||||
`classes/system_search_service.php` lines 713–820 produce something like:
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
|
||||
sci.economic_name, sci.economic_address, ..., sci.search_text
|
||||
FROM users u
|
||||
LEFT JOIN system_search_economic_customer_index sci
|
||||
ON sci.customer_number = u.customer_number
|
||||
WHERE 1=1
|
||||
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
|
||||
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
|
||||
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
|
||||
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
|
||||
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
|
||||
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
|
||||
OR sci.search_text LIKE '%foo%' )
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
|
||||
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
|
||||
|
||||
### 3.2 Order list / transaction history
|
||||
|
||||
`traits/db_object_t.php` lines ~547–556 produce, for a search of `foo` and a filter `customer_id:123`:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM orders_with_invoice_collections
|
||||
WHERE customer_id = 123
|
||||
AND deleted_at IS NULL
|
||||
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
|
||||
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
|
||||
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
|
||||
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
|
||||
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
|
||||
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
|
||||
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
|
||||
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
|
||||
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
```
|
||||
|
||||
* 22 ORed LIKE clauses against the view, all un-indexable.
|
||||
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema snapshots
|
||||
|
||||
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_users_customer_number (customer_number)
|
||||
KEY idx_users_group_id (group_id)
|
||||
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
|
||||
```
|
||||
|
||||
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_orders_customer_id (customer_id)
|
||||
KEY idx_orders_department_id (department_id)
|
||||
KEY idx_orders_invoice_collection_id (invoice_collection_id)
|
||||
KEY idx_orders_reg_1 (reg_1)
|
||||
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
|
||||
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
|
||||
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
|
||||
```
|
||||
|
||||
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (customer_number)
|
||||
INDEX idx_system_search_econ_customer_user (user_id)
|
||||
INDEX idx_system_search_econ_customer_name (economic_name)
|
||||
INDEX idx_system_search_econ_customer_email (economic_email)
|
||||
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
|
||||
-- Missing: FULLTEXT on (search_text)
|
||||
```
|
||||
|
||||
### `system_search_documents` (from `classes/system_search_document_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
INDEX idx_ssd_customer (customer_number)
|
||||
INDEX idx_ssd_department (department_id)
|
||||
INDEX idx_ssd_entity (entity_type)
|
||||
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
|
||||
```
|
||||
|
||||
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
|
||||
|
||||
---
|
||||
|
||||
## 5. EXPLAIN (expected)
|
||||
|
||||
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
|
||||
|
||||
```
|
||||
type: ALL -- full table scan
|
||||
key: NULL
|
||||
rows: N (all users)
|
||||
Extra: Using where
|
||||
```
|
||||
|
||||
For the order list query the expected plan against the view is:
|
||||
|
||||
```
|
||||
type: ALL
|
||||
key: NULL
|
||||
rows: N
|
||||
Extra: Using where; Using filesort
|
||||
```
|
||||
|
||||
Once a FULLTEXT index is added the same queries should become:
|
||||
|
||||
```
|
||||
type: fulltext
|
||||
key: ft_xxx
|
||||
rows: O(log N)
|
||||
Extra: Using where; Ft_hints: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended fixes (ordered by ROI)
|
||||
|
||||
| # | Fix | Estimated effort | Estimated impact | Risk |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
|
||||
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
|
||||
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
|
||||
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
|
||||
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
|
||||
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
|
||||
|
||||
### Recommended sequencing
|
||||
|
||||
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan (this PR)
|
||||
|
||||
This PR ships **F1 only**, as a low-risk drop-in:
|
||||
|
||||
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
|
||||
```sql
|
||||
ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
|
||||
```
|
||||
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
|
||||
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
|
||||
```sql
|
||||
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
|
||||
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
|
||||
```
|
||||
and only fall back to the 13-clause OR if MATCH returns zero rows.
|
||||
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
|
||||
* Stubs `$db` to record the last query.
|
||||
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
|
||||
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
|
||||
|
||||
### What this PR does **not** do
|
||||
|
||||
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
|
||||
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
|
||||
* No schema changes to `users`.
|
||||
* No changes to the cron / dirty-table logic (F2).
|
||||
|
||||
These are tracked as follow-up issues.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test impact
|
||||
|
||||
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
|
||||
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
|
||||
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
|
||||
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
|
||||
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
|
||||
`master` before this change.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / follow-ups
|
||||
|
||||
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
|
||||
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
|
||||
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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*.
|
||||
@@ -0,0 +1,304 @@
|
||||
# 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,4 +7,5 @@
|
||||
|
||||
<!-- AUTO-GENERATED, DO NOT EDIT -->
|
||||
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
|
||||
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
|
||||
</topic>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</chapter>
|
||||
<chapter title="Operation" id="operation">
|
||||
<p>Operation ID: <code>createStripePaymentIntent</code></p>
|
||||
<p>Create Stripe payment intent</p>
|
||||
<p>Creates a Stripe Terminal card payment intent with fixed 25% moms.</p>
|
||||
</chapter>
|
||||
<chapter title="Authentication" id="authentication">
|
||||
<p>Security requirements:</p>
|
||||
@@ -32,9 +32,6 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
+2123
-110
File diff suppressed because it is too large
Load Diff
+49
-40
@@ -1,46 +1,55 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# 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"
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
linter: jetbrains/qodana-php:2026.1
|
||||
|
||||
profile:
|
||||
name: qodana.starter
|
||||
name: qodana.recommended
|
||||
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
php:
|
||||
version: "8.2"
|
||||
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
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
|
||||
|
||||
#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
|
||||
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
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/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);
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/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'
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/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);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <service> [service ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
|
||||
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
|
||||
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
compose_up() {
|
||||
attempt=1
|
||||
while :; do
|
||||
docker network prune -f >/dev/null 2>&1 || true
|
||||
|
||||
if docker compose $compose_files up -d "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
status="$?"
|
||||
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
(
|
||||
flock 9
|
||||
compose_up "$@"
|
||||
) 9>"$lock_file"
|
||||
else
|
||||
echo "flock is not available; running Docker compose startup without a host lock." >&2
|
||||
compose_up "$@"
|
||||
fi
|
||||
@@ -0,0 +1,453 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-command-drain-proof.mjs [options]
|
||||
|
||||
Verifies that a broker-connected PHP compose edge agent still drains API-queued
|
||||
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createWorkerServer(state) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/relay/switch") {
|
||||
state.relaySwitchSeen = true;
|
||||
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
|
||||
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
online: true,
|
||||
on: true,
|
||||
output: true,
|
||||
raw: { source: "fake-worker" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, workerPort) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: "10.123.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, childExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
relaySwitchSeen: state.relaySwitchSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !childExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
relaySwitchSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const workerServer = createWorkerServer(state);
|
||||
let apiServer = null;
|
||||
let child = null;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let childExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const workerPort = await listen(workerServer);
|
||||
apiServer = createApiServer(state, brokerPort, workerPort);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
child = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !childExited) {
|
||||
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, childExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.relaySwitchSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.stdout = stdout.slice(-3000);
|
||||
error.stderr = stderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (child) {
|
||||
await stopChild(child);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(workerServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.stdout) {
|
||||
process.stderr.write(`stdout: ${error.stdout}\n`);
|
||||
}
|
||||
if (error.stderr) {
|
||||
process.stderr.write(`stderr: ${error.stderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_WORKER_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const AGENT_TOKEN = "agent-token";
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
workerPath: DEFAULT_WORKER_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--worker-path":
|
||||
options.workerPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-to-shelly-proof.mjs [options]
|
||||
|
||||
Runs the PHP edge agent and real LAN worker against fake broker, API, and
|
||||
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
|
||||
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
|
||||
call, reads Switch.GetStatus, and posts the command result.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--worker-path <path> PHP LAN worker artifact to execute.
|
||||
Default: ${DEFAULT_WORKER_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === null ? null : JSON.stringify(body);
|
||||
const request = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: requestPath,
|
||||
method,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(payload === null ? {} : {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
}),
|
||||
...headers,
|
||||
},
|
||||
timeout: 1000,
|
||||
}, (response) => {
|
||||
let raw = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
decoded = { __invalid: raw };
|
||||
}
|
||||
resolve({ status: response.statusCode || 0, body: decoded });
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error("request timed out"));
|
||||
});
|
||||
if (payload !== null) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function reservePort() {
|
||||
const server = net.createServer();
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
await closeServer(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createShellyServer(state) {
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
state.requests.push({
|
||||
service: "shelly",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
|
||||
state.shellySwitchSetSeen = true;
|
||||
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { was_on: false, output: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
|
||||
state.shellyStatusSeen = true;
|
||||
if (url.searchParams.get("id") !== "0") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/relay/")) {
|
||||
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, shellyAddress) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: shellyAddress,
|
||||
local_ip: shellyAddress,
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
relay_id: "relay-proof",
|
||||
deviceGeneration: 2,
|
||||
device_generation: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (
|
||||
body.ok !== true ||
|
||||
body.result?.on !== true ||
|
||||
body.result?.output !== true ||
|
||||
body.result?.raw?.source !== "fake-shelly-rpc"
|
||||
) {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: AGENT_TOKEN,
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnWorker({ workerPath, phpImage, workerPort }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-e",
|
||||
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
|
||||
"-v",
|
||||
`${workerPath}:/lan-worker.php:ro`,
|
||||
phpImage,
|
||||
"php",
|
||||
"-S",
|
||||
`127.0.0.1:${workerPort}`,
|
||||
"/lan-worker.php",
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function waitForWorker(workerPort, child, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestJson({ port: workerPort, path: "/health" });
|
||||
if (response.status === 200 && response.body?.service === "lan-worker") {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw lastError || new Error("LAN worker did not become healthy");
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, agentExited, workerExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
shellySwitchSetSeen: state.shellySwitchSetSeen,
|
||||
shellyStatusSeen: state.shellyStatusSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !agentExited,
|
||||
workerStayedRunningUntilProofComplete: !workerExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
if (!fs.existsSync(options.workerPath)) {
|
||||
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
shellySwitchSetSeen: false,
|
||||
shellyStatusSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const shellyServer = createShellyServer(state);
|
||||
let apiServer = null;
|
||||
let agent = null;
|
||||
let worker = null;
|
||||
let agentStdout = "";
|
||||
let agentStderr = "";
|
||||
let workerStdout = "";
|
||||
let workerStderr = "";
|
||||
let agentExited = false;
|
||||
let workerExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const shellyPort = await listen(shellyServer);
|
||||
const workerPort = await reservePort();
|
||||
const shellyAddress = `127.0.0.1:${shellyPort}`;
|
||||
apiServer = createApiServer(state, brokerPort, shellyAddress);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
worker = spawnWorker({ ...options, workerPort });
|
||||
worker.stdout.on("data", (chunk) => {
|
||||
workerStdout += chunk.toString();
|
||||
});
|
||||
worker.stderr.on("data", (chunk) => {
|
||||
workerStderr += chunk.toString();
|
||||
});
|
||||
worker.once("exit", () => {
|
||||
workerExited = true;
|
||||
});
|
||||
await waitForWorker(workerPort, worker, 5000);
|
||||
|
||||
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
agent.stdout.on("data", (chunk) => {
|
||||
agentStdout += chunk.toString();
|
||||
});
|
||||
agent.stderr.on("data", (chunk) => {
|
||||
agentStderr += chunk.toString();
|
||||
});
|
||||
agent.once("exit", () => {
|
||||
agentExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
|
||||
if (
|
||||
state.brokerHandshakeSeen &&
|
||||
state.commandPollSeen &&
|
||||
state.shellySwitchSetSeen &&
|
||||
state.shellyStatusSeen &&
|
||||
state.resultSeen
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, agentExited, workerExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.shellySwitchSetSeen ||
|
||||
!state.shellyStatusSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.agentStdout = agentStdout.slice(-3000);
|
||||
error.agentStderr = agentStderr.slice(-3000);
|
||||
error.workerStdout = workerStdout.slice(-3000);
|
||||
error.workerStderr = workerStderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
workerPath: options.workerPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (agent) {
|
||||
await stopChild(agent);
|
||||
}
|
||||
if (worker) {
|
||||
await stopChild(worker);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(shellyServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.agentStdout) {
|
||||
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
|
||||
}
|
||||
if (error.agentStderr) {
|
||||
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
|
||||
}
|
||||
if (error.workerStdout) {
|
||||
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
|
||||
}
|
||||
if (error.workerStderr) {
|
||||
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+130
-62
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
const websocketUrl = new URL(String(rawUrl));
|
||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = ciBrokerPort;
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||
websocketUrl.hostname = "edge-broker";
|
||||
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
|
||||
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = apiUrl.port;
|
||||
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
|
||||
}
|
||||
|
||||
if (isLocalHost(websocketUrl.hostname)) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
@@ -201,11 +227,7 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/already exists|already connected/i.test(stderr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return /already exists|already connected/i.test(stderr);
|
||||
}
|
||||
|
||||
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
|
||||
@@ -555,6 +577,38 @@ function summarizeStreamMessages(messages, limit = 12) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
|
||||
const diagnostics = {};
|
||||
|
||||
if (gatewayId !== null && gatewayId > 0 && authToken) {
|
||||
try {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
diagnostics.gateway = {
|
||||
status: detail?.data?.status ?? null,
|
||||
channelStatus: detail?.data?.channel_status ?? null,
|
||||
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
|
||||
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
|
||||
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
|
||||
allowFailure: true,
|
||||
});
|
||||
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
|
||||
} catch (error) {
|
||||
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = await resolveRootDir(scriptPath);
|
||||
@@ -669,22 +723,35 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
try {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await readGatewayDiagnostics({
|
||||
baseUrl,
|
||||
authToken,
|
||||
gatewayId,
|
||||
containerName,
|
||||
});
|
||||
throw new Error([
|
||||
error instanceof Error ? error.message : String(error),
|
||||
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
@@ -707,11 +774,14 @@ async function main() {
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
||||
assert.equal(
|
||||
Boolean(readyMessage?.connected),
|
||||
true,
|
||||
"Gateway stream became ready before the broker reported the gateway as connected."
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => (
|
||||
message?.type === "gateway.stream.ready" && message?.connected === true
|
||||
) || (
|
||||
message?.type === "presence.changed" && message?.status === "connected"
|
||||
),
|
||||
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
@@ -739,13 +809,27 @@ async function main() {
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
try {
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED"
|
||||
|| streamMessages.some((message) => (
|
||||
message?.type === "task.updated"
|
||||
&& Number(message?.operationId || 0) === operationId
|
||||
&& message?.operation?.status === "COMPLETED"
|
||||
));
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot = null;
|
||||
let operationSnapshot;
|
||||
try {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
@@ -765,21 +849,6 @@ async function main() {
|
||||
throw new Error(diagnostic);
|
||||
}
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
@@ -855,22 +924,20 @@ async function main() {
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
@@ -888,9 +955,10 @@ async function main() {
|
||||
allowFailure: true,
|
||||
}).catch(() => {});
|
||||
|
||||
if (gatewayId !== null && fixture?.auth_token) {
|
||||
const fixtureAuthToken = fixture?.auth_token;
|
||||
if (gatewayId !== null && fixtureAuthToken) {
|
||||
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
||||
token: String(fixture.auth_token),
|
||||
token: String(fixtureAuthToken),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
|
||||
' title="API Reference" id="API-Reference">\n'
|
||||
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
||||
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
||||
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
||||
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
||||
"successful HTTP request handled by the broker container and defaults to the container's "
|
||||
"start time when no request has been processed yet.</p>\n"
|
||||
"</topic>\n"
|
||||
)
|
||||
|
||||
|
||||
+23
-3
@@ -18,6 +18,7 @@ cd "$repo_root"
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
@@ -84,6 +85,17 @@ composer_install() {
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||
}
|
||||
|
||||
configure_ci_docker_subnet() {
|
||||
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -103,7 +115,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||
configure_ci_docker_subnet
|
||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
@@ -126,16 +139,23 @@ tar \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
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 -C /var/www/repo-root -xf -
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Pre-deploy schema bootstrap runner.
|
||||
*
|
||||
* Loads and runs every `*_schema_bootstrap` class so the production
|
||||
* database has all the columns the current code expects. Each
|
||||
* bootstrap is additive and idempotent — safe to run on every deploy.
|
||||
*
|
||||
* Run via:
|
||||
* php scripts/run-schema-bootstraps.php
|
||||
*
|
||||
* Used in .github/workflows/deploy.yml as a pre-deploy step.
|
||||
*
|
||||
* When you add a new *_schema_bootstrap class, you don't need to
|
||||
* edit this file — the runner auto-discovers any class whose name
|
||||
* ends in `_schema_bootstrap`.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
// Load the app entry point so $db is wired up the same way as in
|
||||
// normal request handling.
|
||||
$index = __DIR__ . '/../services/nginx/app/index.php';
|
||||
if (!file_exists($index)) {
|
||||
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
|
||||
exit(2);
|
||||
}
|
||||
require_once $index;
|
||||
|
||||
$classesDir = __DIR__ . '/../services/nginx/app/classes';
|
||||
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$ran = 0;
|
||||
$skipped = 0;
|
||||
foreach ($bootstraps as $file) {
|
||||
require_once $file;
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
if (!class_exists($class)) {
|
||||
fwrite(STDERR, " [skip] {$base}: class not found\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$class::ensureSchema();
|
||||
echo " [ok] {$base}\n";
|
||||
$ran++;
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Schema health check — verifies all required DB columns exist.
|
||||
*
|
||||
* Run via:
|
||||
* GET /api/admin/schema-check (returns JSON report)
|
||||
* php scripts/schema-health-check.php (CLI, exits 0/1)
|
||||
*
|
||||
* Lists the columns that the code expects to find in each critical
|
||||
* table. If a column is missing, the response is 503 (HTTP) or
|
||||
* exit code 1 (CLI) — clearly distinct from a generic 500.
|
||||
*
|
||||
* Add to the list when introducing a new optional column.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
|
||||
|
||||
use classes\customer_invoice_email_schema_bootstrap;
|
||||
|
||||
const SCHEMA_REQUIREMENTS = [
|
||||
'users' => [
|
||||
'invoice_email', // TRU-77 (added 2026-08-16)
|
||||
'wash_certificate_email',
|
||||
'email',
|
||||
'customer_number',
|
||||
],
|
||||
'invoices' => [
|
||||
'po_number',
|
||||
'closed_at',
|
||||
'customer_number',
|
||||
],
|
||||
'bookings' => [
|
||||
'id',
|
||||
'customer_number',
|
||||
'department',
|
||||
],
|
||||
];
|
||||
|
||||
function check_schema(): array
|
||||
{
|
||||
global $db;
|
||||
$report = [
|
||||
'ok' => true,
|
||||
'missing' => [],
|
||||
'tables_checked' => 0,
|
||||
'columns_checked' => 0,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
$report['ok'] = false;
|
||||
$report['error'] = 'no_db_connection';
|
||||
return $report;
|
||||
}
|
||||
|
||||
// First: run the schema bootstrap (additive, idempotent) so we
|
||||
// give the DB a chance to self-heal.
|
||||
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
|
||||
$report['tables_checked']++;
|
||||
|
||||
// Confirm the table itself exists
|
||||
$tableSafe = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
||||
if (!$result || (int)$result->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "table `{$table}` does not exist";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$report['columns_checked']++;
|
||||
$colSafe = str_replace("'", '', $column);
|
||||
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
||||
if (!$r || (int)$r->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$report = check_schema();
|
||||
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
|
||||
exit($report['ok'] ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generic smoke test for any deployed app.
|
||||
#
|
||||
# Usage: ./scripts/smoke-test.sh [base_url]
|
||||
# Default: https://staging.truckwash.io
|
||||
#
|
||||
# Required env vars (set by GitHub Action):
|
||||
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
|
||||
#
|
||||
# Optional env vars:
|
||||
# SMOKE_TOKEN - bearer token for authenticated checks
|
||||
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
|
||||
#
|
||||
# Exits 0 on all-pass, 1 on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
|
||||
TIMEOUT="${SMOKE_TIMEOUT:-10}"
|
||||
|
||||
# Color codes
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local expected="${3:-200}"
|
||||
local method="${4:-GET}"
|
||||
|
||||
local status
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
|
||||
|
||||
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} $name ($status) — $url"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $name (expected $expected, got $status) — $url"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Smoke test against $BASE_URL"
|
||||
echo " (timeout ${TIMEOUT}s per check)"
|
||||
echo
|
||||
|
||||
# === Health endpoints (universal) ===
|
||||
check "health check" "$BASE_URL/healthz" "2xx"
|
||||
check "ping" "$BASE_URL/api/ping" "2xx"
|
||||
|
||||
# === Authentication (should NOT 500) ===
|
||||
check "login page" "$BASE_URL/login" "2xx"
|
||||
|
||||
# === Public endpoints (api repo) ===
|
||||
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
|
||||
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
|
||||
|
||||
# === Public endpoints (pleno-vue) ===
|
||||
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
|
||||
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
|
||||
|
||||
# === Custom 404 should not 500 ===
|
||||
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
|
||||
|
||||
# === Optional authenticated check ===
|
||||
if [ -n "${SMOKE_TOKEN:-}" ]; then
|
||||
check "auth check" "$BASE_URL/api/me" "2xx"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All smoke tests passed${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Some smoke tests failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,14 +1,32 @@
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
|
||||
export const REQUIRED_MANIFEST_ARTIFACTS = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
];
|
||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||
"/edge-agent/install-token/status",
|
||||
"/edge-agent/artifacts/manifest.json",
|
||||
"report_install_status",
|
||||
'begin_install_phase "VERIFY_TOKEN"',
|
||||
'begin_install_phase "VERIFY_ARTIFACTS"',
|
||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||
'report_install_status "FAILED"',
|
||||
"verify_manifest_artifact",
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
];
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
|
||||
name: "Ping",
|
||||
url: `${normalizedBaseUrl}/ping`,
|
||||
},
|
||||
{
|
||||
name: "Artifact manifest",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
||||
artifactName: "manifest.json",
|
||||
},
|
||||
{
|
||||
name: "Agent PHP artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||
artifactName: "agent.php",
|
||||
},
|
||||
{
|
||||
name: "Service unit artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||
artifactName: "truckwash-edge-agent.service",
|
||||
},
|
||||
{
|
||||
name: "Installer script",
|
||||
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
|
||||
];
|
||||
}
|
||||
|
||||
export function validateArtifactManifestBody(body) {
|
||||
const manifest = JSON.parse(String(body || ""));
|
||||
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
|
||||
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
|
||||
}
|
||||
if (!Array.isArray(manifest.artifacts)) {
|
||||
throw new Error("Artifact manifest is missing artifacts.");
|
||||
}
|
||||
|
||||
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
|
||||
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
|
||||
if (missingArtifacts.length) {
|
||||
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
|
||||
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (sha256 !== artifact.sha256) {
|
||||
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
|
||||
}
|
||||
if (buffer.length !== artifact.bytes) {
|
||||
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function validateInstallerScriptBody(body) {
|
||||
const source = String(body || "");
|
||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
|
||||
const checks = buildChecks(baseUrl, installToken);
|
||||
const results = [];
|
||||
let artifactManifest = null;
|
||||
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||
const response = await fetch(check.url);
|
||||
const body = await response.text();
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
const result = {
|
||||
...check,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
bodyPreview: previewBody(body),
|
||||
bodyPreview: previewBody(body.toString("utf8")),
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (check.name === "Artifact manifest") {
|
||||
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
|
||||
result.version = artifactManifest.version;
|
||||
result.artifactCount = artifactManifest.artifacts.length;
|
||||
}
|
||||
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
|
||||
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
|
||||
}
|
||||
if (check.name === "Installer script") {
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_STAGING_BASE_URL,
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||
buildChecks,
|
||||
normalizeBaseUrl,
|
||||
parseArgs,
|
||||
validateArtifactBodyAgainstManifest,
|
||||
validateArtifactManifestBody,
|
||||
validateInstallerScriptBody,
|
||||
} from "./staging-edge-gateway-smoke.mjs";
|
||||
|
||||
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
|
||||
|
||||
assert.deepEqual(checks.map((check) => check.url), [
|
||||
"https://api.truckwash.io:4433/ping",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validateArtifactManifestBody requires v3 install artifacts", () => {
|
||||
const artifacts = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
|
||||
|
||||
const manifest = validateArtifactManifestBody(JSON.stringify({
|
||||
version: EXPECTED_INSTALL_VERSION,
|
||||
artifacts,
|
||||
}));
|
||||
|
||||
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
|
||||
});
|
||||
|
||||
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
|
||||
const body = Buffer.from("hello");
|
||||
const manifest = {
|
||||
artifacts: [{
|
||||
name: "agent.php",
|
||||
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
bytes: body.length,
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||
const script = `
|
||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
|
||||
report_install_status "FAILED"
|
||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||
verify_manifest_artifact
|
||||
${EXPECTED_INSTALL_VERSION}
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
||||
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||
@@ -409,14 +409,20 @@ async function startContainer({
|
||||
});
|
||||
|
||||
if (copyConfig) {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
||||
try {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
configFilePath,
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand("docker", ["start", containerName], {
|
||||
cwd: rootDir,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env php8.4
|
||||
<?php
|
||||
/**
|
||||
* Live verification of e-conomic draft invoice creation using customer 12345679.
|
||||
*
|
||||
* This script:
|
||||
* 1. Connects to the real e-conomic API (requires env credentials)
|
||||
* 2. Creates a draft invoice for customer 12345679 with TEST items
|
||||
* 3. Verifies the draft was created correctly
|
||||
* 4. DELETES the draft to clean up
|
||||
*
|
||||
* Usage (on production server with credentials):
|
||||
* php8.4 verify-economic-drafts-live.php
|
||||
*
|
||||
* Required env vars (set in .env or pass inline):
|
||||
* ECONOMIC_API_APP_ACCESS_GRANT
|
||||
* ECONOMIC_API_APP_SECRET_TOKEN
|
||||
*
|
||||
* Optional:
|
||||
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
|
||||
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = all verifications passed, draft cleaned up
|
||||
* 1 = error during verification
|
||||
* 2 = cleanup failed (draft still exists, manual intervention required)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. Load credentials
|
||||
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
|
||||
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
|
||||
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
|
||||
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
|
||||
|
||||
if (!$grant || !$secret) {
|
||||
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
|
||||
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
|
||||
|
||||
/**
|
||||
* Send a request to the e-conomic API.
|
||||
*
|
||||
* @return array{status: int, body: string, json?: array}
|
||||
*/
|
||||
function econ_request(string $method, string $url, ?array $body = null): array
|
||||
{
|
||||
global $auth;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
trim(explode("\r\n", $auth)[0]),
|
||||
trim(explode("\r\n", $auth)[1]),
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'body' => $error];
|
||||
}
|
||||
$json = json_decode($response, true);
|
||||
return ['status' => $status, 'body' => $response, 'json' => $json];
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = null;
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$total = 0;
|
||||
|
||||
function check(string $name, bool $ok, string $detail = ''): void
|
||||
{
|
||||
global $pass, $fail, $total;
|
||||
$total++;
|
||||
if ($ok) {
|
||||
$pass++;
|
||||
echo " ✅ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
} else {
|
||||
$fail++;
|
||||
echo " ❌ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== E-conomic Live Draft Verification ===\n";
|
||||
echo "Customer: $customer\n";
|
||||
echo "API base: $baseUrl\n\n";
|
||||
|
||||
try {
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Verify customer exists
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 1: Verify customer $customer exists...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/customers/$customer");
|
||||
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 200) {
|
||||
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$customerName = $resp['json']['name'] ?? 'unknown';
|
||||
echo " Customer name: $customerName\n\n";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 2: Create draft invoice
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 2: Create draft invoice for customer $customer...\n";
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
|
||||
'currency' => 'DKK',
|
||||
'customer' => ['customerNumber' => $customer],
|
||||
'paymentTerms' => ['paymentTermsNumber' => 1],
|
||||
'layout' => ['layoutNumber' => 1],
|
||||
'recipient' => ['name' => 'OpenClaw Live Verification'],
|
||||
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
|
||||
]);
|
||||
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 201) {
|
||||
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
|
||||
echo " Draft invoice number: $draftInvoiceNumber\n\n";
|
||||
|
||||
if (!$draftInvoiceNumber) {
|
||||
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 3: Add test lines to draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
|
||||
$lines = [
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
|
||||
'quantity' => 1.0,
|
||||
'unitNetPrice' => 100.00,
|
||||
'discountPercentage' => 0.0,
|
||||
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
|
||||
'quantity' => 2.0,
|
||||
'unitNetPrice' => 200.00,
|
||||
'discountPercentage' => 15.0,
|
||||
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
];
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
|
||||
'lines' => $lines,
|
||||
]);
|
||||
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 4: Verify draft contents
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 4: Verify draft contents...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
$draft = $resp['json'] ?? [];
|
||||
$draftLines = $draft['lines'] ?? [];
|
||||
|
||||
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
|
||||
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
|
||||
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
|
||||
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 5: Cleanup - delete the draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
|
||||
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 6: Verify deletion
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 6: Verify draft is gone...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
// Best-effort cleanup
|
||||
if ($draftInvoiceNumber !== null) {
|
||||
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
echo " Cleanup HTTP status: {$resp['status']}\n";
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\n=== Summary: $pass/$total checks passed ===\n";
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
Vendored
+58
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||
const commands = Array.isArray(command?.payload?.commands)
|
||||
? command.payload.commands
|
||||
: Array.isArray(command?.commands)
|
||||
? command.commands
|
||||
: [];
|
||||
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||
|
||||
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||
const target = String(entry.target || entry.relay || "");
|
||||
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||
try {
|
||||
const payload = await handler(entry, fetchImpl);
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||
case "SET_RELAY_STATE":
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "BATCH_RELAY_STATUS":
|
||||
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||
case "BATCH_SET_RELAY_STATE":
|
||||
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
|
||||
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||
const fakeFetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("10.1.0.31")) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("relay offline");
|
||||
};
|
||||
|
||||
const result = await handleAgentCommand({
|
||||
commandType: "BATCH_SET_RELAY_STATE",
|
||||
payload: {
|
||||
batch_id: "batch-1",
|
||||
commands: [
|
||||
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||
],
|
||||
},
|
||||
}, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.equal(result.batch_id, "batch-1");
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].target, "MACHINE");
|
||||
assert.equal(result.results[1].ok, false);
|
||||
assert.match(result.results[1].error, /relay offline/);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+37
@@ -40,6 +40,10 @@ 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
|
||||
@@ -54,6 +58,8 @@ 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;
|
||||
@@ -71,6 +77,7 @@ class Receiver extends Writable {
|
||||
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
this._errored = false;
|
||||
@@ -89,6 +96,22 @@ 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);
|
||||
@@ -478,6 +501,19 @@ 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);
|
||||
@@ -550,6 +586,7 @@ class Receiver extends Writable {
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._fragmented = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
if (this._opcode === 2) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@
|
||||
|
||||
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');
|
||||
@@ -200,8 +203,10 @@ class Sender {
|
||||
|
||||
if (typeof data === 'string') {
|
||||
buf.write(data, 2);
|
||||
} else {
|
||||
} else if (isUint8Array(data)) {
|
||||
buf.set(data, 2);
|
||||
} else {
|
||||
throw new TypeError('Second argument must be a string or a Uint8Array');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -43,6 +43,10 @@ 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
|
||||
@@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter {
|
||||
options = {
|
||||
allowSynchronousEvents: true,
|
||||
autoPong: true,
|
||||
maxBufferedChunks: 256 * 1024,
|
||||
maxFragments: 16 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
skipUTF8Validation: false,
|
||||
perMessageDeflate: false,
|
||||
@@ -424,6 +430,8 @@ 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,6 +201,10 @@ 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
|
||||
@@ -212,6 +216,8 @@ 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
|
||||
});
|
||||
@@ -640,6 +646,10 @@ 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
|
||||
@@ -660,6 +670,8 @@ 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,
|
||||
@@ -1017,6 +1029,8 @@ 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
|
||||
});
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws",
|
||||
"version": "8.20.0",
|
||||
"version": "8.21.1",
|
||||
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
||||
"keywords": [
|
||||
"HyBi",
|
||||
@@ -66,5 +66,9 @@
|
||||
"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.18.0"
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"test:live": "node --test live/live-smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}, managerUrl = "") {
|
||||
function resolveAuthMode(options = {}) {
|
||||
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, managerUrl);
|
||||
const authMode = resolveAuthMode(options);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
|
||||
|
||||
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
const containerStartedAt = currentTimestamp();
|
||||
let lastActivityAt = containerStartedAt;
|
||||
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
@@ -296,6 +298,12 @@ export function createBrokerServer(options = {}) {
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
const ingestMachineSignal =
|
||||
options.ingestMachineSignal ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
@@ -474,6 +482,7 @@ export function createBrokerServer(options = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
lastActivityAt = currentTimestamp();
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -482,6 +491,7 @@ export function createBrokerServer(options = {}) {
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
lastActivityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -853,6 +863,11 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MACHINE_SIGNAL") {
|
||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
@@ -1072,6 +1087,10 @@ export function createBrokerServer(options = {}) {
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
containerStartedAt,
|
||||
get lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
assert.equal(typeof healthJson.lastActivityAt, "string");
|
||||
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
|
||||
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker updates lastActivityAt after each successful request", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
|
||||
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const firstJson = await firstResponse.json();
|
||||
const firstActivityAt = broker.state.lastActivityAt;
|
||||
|
||||
assert.equal(typeof firstJson.lastActivityAt, "string");
|
||||
assert.equal(firstJson.lastActivityAt, firstActivityAt);
|
||||
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
|
||||
assert.ok(broker.state.lastActivityAt > firstActivityAt);
|
||||
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const secondJson = await secondResponse.json();
|
||||
|
||||
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -766,6 +805,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||
const machineSignals = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
ingestMachineSignal: async (gatewayId, payload) => {
|
||||
machineSignals.push({ gatewayId, payload });
|
||||
return { recorded: true, lane_id: payload.lane_id };
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "MACHINE_SIGNAL",
|
||||
payload: {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||
assert.equal(machineSignals[0].gatewayId, "701");
|
||||
assert.deepEqual(machineSignals[0].payload, {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
|
||||
@@ -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*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
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, /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*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
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, /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*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
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, /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*\$\{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\}/);
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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*\$\{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\}/);
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12452,3 +12452,875 @@
|
||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
|
||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
|
||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
|
||||
[Thu Jun 4 05:49:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41145) started
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Closing
|
||||
[Thu Jun 4 05:49:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45671) started
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Closing
|
||||
[Thu Jun 4 05:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33783) started
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Closing
|
||||
[Thu Jun 4 06:06:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36081) started
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58454 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58454 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Closing
|
||||
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Accepted
|
||||
[Thu Jun 4 06:07:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Closing
|
||||
[Thu Jun 4 06:18:18 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45139) started
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Accepted
|
||||
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Closing
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40646 Accepted
|
||||
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40646 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Closing
|
||||
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Accepted
|
||||
[Thu Jun 4 06:18:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Closing
|
||||
[Thu Jun 4 06:31:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40389) started
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60916 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60916 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60964 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60964 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Closing
|
||||
[Fri Jun 12 12:04:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44515) started
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34274 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34274 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34290 Accepted
|
||||
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34290 Closing
|
||||
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34296 Accepted
|
||||
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34296 Closing
|
||||
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34304 Accepted
|
||||
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34304 Closing
|
||||
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34314 Accepted
|
||||
[Fri Jun 12 12:04:20 2026] 127.0.0.1:34314 Closing
|
||||
[Fri Jun 12 12:19:47 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35009) started
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Closing
|
||||
[Fri Jun 12 12:23:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33981) started
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Closing
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Closing
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Closing
|
||||
[Fri Jun 12 12:39:31 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43643) started
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59078 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59078 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59470 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59470 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Closing
|
||||
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Accepted
|
||||
[Fri Jun 12 12:39:41 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:41 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Closing
|
||||
[Fri Jun 12 12:41:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45327) started
|
||||
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Accepted
|
||||
[Fri Jun 12 12:41:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:41:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Closing
|
||||
[Fri Jun 12 12:42:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44757) started
|
||||
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Accepted
|
||||
[Fri Jun 12 12:42:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:42:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Closing
|
||||
[Fri Jun 12 12:43:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43731) started
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Closing
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Closing
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Closing
|
||||
[Fri Jun 12 12:53:28 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41773) started
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Accepted
|
||||
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Closing
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Accepted
|
||||
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Closing
|
||||
[Fri Jun 12 13:42:40 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45259) started
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Accepted
|
||||
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Closing
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Accepted
|
||||
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Closing
|
||||
[Fri Jun 12 13:45:11 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43693) started
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Closing
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Closing
|
||||
[Fri Jun 12 13:45:12 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37353) started
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Closing
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Closing
|
||||
[Fri Jun 12 13:49:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:32781) started
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Accepted
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Closing
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Accepted
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Closing
|
||||
[Fri Jun 12 13:49:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33723) started
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Accepted
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Closing
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Accepted
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Closing
|
||||
[Fri Jun 12 13:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34549) started
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Accepted
|
||||
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Closing
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59342 Accepted
|
||||
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:59342 Closing
|
||||
[Fri Jun 12 13:49:58 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36181) started
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Accepted
|
||||
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Closing
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Accepted
|
||||
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Closing
|
||||
[Fri Jun 12 14:03:34 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40101) started
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Accepted
|
||||
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Closing
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Accepted
|
||||
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Closing
|
||||
[Fri Jun 12 14:03:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33543) started
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Accepted
|
||||
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Closing
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Accepted
|
||||
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Closing
|
||||
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46061) started
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Accepted
|
||||
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Closing
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Accepted
|
||||
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Closing
|
||||
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34023) started
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Accepted
|
||||
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Closing
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Accepted
|
||||
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Closing
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class account_deletion_http_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?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
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Static utility for generating, formatting, hashing, and parsing
|
||||
* API keys.
|
||||
*
|
||||
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
|
||||
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
|
||||
*
|
||||
* The key_id (everything before the dot) is stored in plain text in
|
||||
* the database as the lookup key. The secret is NEVER stored in plain
|
||||
* text — only the argon2id hash is persisted. The full key is shown
|
||||
* to the user exactly once at creation time.
|
||||
*/
|
||||
class api_key_generator
|
||||
{
|
||||
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
|
||||
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** Characters permitted in the public key_id portion. */
|
||||
public const KEY_ID_RANDOM_LENGTH = 22;
|
||||
|
||||
/** Characters in the secret portion. */
|
||||
public const SECRET_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Build the public key_id portion: <prefix>_<env>_<random>.
|
||||
*/
|
||||
public static function generateKeyId(string $env = 'live'): string
|
||||
{
|
||||
$env = self::normaliseEnv($env);
|
||||
$prefix = self::prefix();
|
||||
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
|
||||
return $prefix . '_' . $env . '_' . $random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the secret portion (32-char base62).
|
||||
*/
|
||||
public static function generateSecret(): string
|
||||
{
|
||||
return self::randomBase62(self::SECRET_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join key_id and secret with a single dot.
|
||||
*/
|
||||
public static function formatKey(string $keyId, string $secret): string
|
||||
{
|
||||
if ($keyId === '' || strpos($keyId, '.') !== false) {
|
||||
throw new \InvalidArgumentException('key_id must not contain a dot');
|
||||
}
|
||||
if ($secret === '' || strpos($secret, '.') !== false) {
|
||||
throw new \InvalidArgumentException('secret must not contain a dot');
|
||||
}
|
||||
return $keyId . '.' . $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the full key (or just the secret) using argon2id.
|
||||
*/
|
||||
public static function hash(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
throw new \InvalidArgumentException('Cannot hash an empty value');
|
||||
}
|
||||
$hash = password_hash($plain, PASSWORD_ARGON2ID);
|
||||
if ($hash === false) {
|
||||
throw new \RuntimeException('Failed to hash with argon2id');
|
||||
}
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plaintext key against a stored argon2id hash.
|
||||
*/
|
||||
public static function verify(string $plain, string $hash): bool
|
||||
{
|
||||
if ($plain === '' || $hash === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return password_verify($plain, $hash);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full "key_id.secret" string back into its parts.
|
||||
*
|
||||
* The key_id may contain underscores (as separators between
|
||||
* prefix/env/random) and must be base62 + underscores. The
|
||||
* secret must be strictly base62 with no separators.
|
||||
*
|
||||
* @return array{key_id:string, secret:string}|null
|
||||
* null if the input is malformed.
|
||||
*/
|
||||
public static function parseKey(string $full): ?array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '' || strpos($full, '.') === false) {
|
||||
return null;
|
||||
}
|
||||
// Split on the FIRST dot only — secrets are base62 and contain
|
||||
// no dots, so there's exactly one separator.
|
||||
$parts = explode('.', $full, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
[$keyId, $secret] = $parts;
|
||||
$keyId = trim($keyId);
|
||||
$secret = trim($secret);
|
||||
if ($keyId === '' || $secret === '') {
|
||||
return null;
|
||||
}
|
||||
// The key_id is "<prefix>_<env>_<random>" — base62 with
|
||||
// underscore separators. The secret is pure base62.
|
||||
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
|
||||
return null;
|
||||
}
|
||||
return ['key_id' => $keyId, 'secret' => $secret];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a key_id string: base62 with optional underscore
|
||||
* separators. Exposed for testing.
|
||||
*/
|
||||
public static function isKeyId(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurable prefix (default: "truck"). Reads from
|
||||
* `config('api_key.prefix', 'truck')` if available, otherwise the
|
||||
* default. Always lowercased and stripped of separators.
|
||||
*/
|
||||
public static function prefix(): string
|
||||
{
|
||||
$default = 'truck';
|
||||
$value = $default;
|
||||
if (function_exists('config')) {
|
||||
try {
|
||||
$candidate = config('api_key.prefix', $default);
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
$value = $candidate;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$value = $default;
|
||||
}
|
||||
}
|
||||
$value = strtolower(trim((string)$value));
|
||||
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
$value = $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function randomBase62(int $length): string
|
||||
{
|
||||
if ($length < 1) {
|
||||
throw new \InvalidArgumentException('Length must be positive');
|
||||
}
|
||||
|
||||
$alphabet = self::ALPHABET;
|
||||
$alphabetMax = strlen($alphabet) - 1; // 61
|
||||
|
||||
$out = '';
|
||||
$bytesNeeded = (int)ceil($length * 1.3) + 8;
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
|
||||
while (strlen($out) < $length) {
|
||||
if (!isset($bytes[$byteIndex])) {
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
}
|
||||
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
|
||||
// avoid modulo bias.
|
||||
$byte = ord($bytes[$byteIndex]);
|
||||
$byteIndex++;
|
||||
$value = $byte & 0x3F;
|
||||
if ($value > $alphabetMax) {
|
||||
continue;
|
||||
}
|
||||
$out .= $alphabet[$value];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function isBase62(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
private static function normaliseEnv(string $env): string
|
||||
{
|
||||
$trimmed = strtolower(trim($env));
|
||||
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
|
||||
// If the input contained characters outside the allowed
|
||||
// set, the sanitised result will differ from the trimmed
|
||||
// input — in that case fall back to "live" rather than
|
||||
// echoing a mangled version. Empty / whitespace-only input
|
||||
// also falls back to "live".
|
||||
if ($sanitised === '' || $sanitised !== $trimmed) {
|
||||
return 'live';
|
||||
}
|
||||
return $sanitised;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Repository for the `api_keys` table.
|
||||
*
|
||||
* This is a thin procedural wrapper that uses the project's existing
|
||||
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
|
||||
* other repositories in this codebase (see `classes/orders_o.php`,
|
||||
* `classes/invoice_store.php`, etc.).
|
||||
*
|
||||
* Records are returned as associative arrays. The caller is expected
|
||||
* to interact with them as plain dicts; there is no dedicated model
|
||||
* class for api keys.
|
||||
*/
|
||||
class api_key_repository
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static function db()
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
throw new Exception('Database connection ($db) is not available');
|
||||
}
|
||||
// Lazy-create the table on first use so callers don't have to
|
||||
// remember to call ensureTables().
|
||||
if (class_exists(api_key_schema_bootstrap::class)) {
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data for create(). Exposed so test doubles
|
||||
* can exercise the same validation without touching a real DB.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function validate(array $data): void
|
||||
{
|
||||
$required = ['key_id', 'key_hash', 'name', 'role'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
|
||||
throw new \InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
|
||||
if (!in_array($data['role'], $allowedRoles, true)) {
|
||||
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int inserted id
|
||||
*/
|
||||
public static function create(array $data): int
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
$db = self::db();
|
||||
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
|
||||
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
|
||||
: null;
|
||||
|
||||
$stmt = $db->conn()->prepare(
|
||||
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
|
||||
}
|
||||
|
||||
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
|
||||
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
|
||||
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
|
||||
? (string)$data['expires_at']
|
||||
: null;
|
||||
|
||||
$stmt->bind_param(
|
||||
'sssssiss',
|
||||
$data['key_id'],
|
||||
$data['key_hash'],
|
||||
$data['name'],
|
||||
$data['role'],
|
||||
$scopesJson,
|
||||
$customerId,
|
||||
$createdBy,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to insert api_key: ' . $err);
|
||||
}
|
||||
$id = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return (int)$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-revoked key by its public key_id.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
if ($keyId === '') {
|
||||
return null;
|
||||
}
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('s', $keyId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any key by id (including revoked).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
|
||||
*/
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump last_used_at for a key. Best-effort: failures are swallowed
|
||||
* because this is a hot-path observability hook and must not
|
||||
* break the request.
|
||||
*/
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
try {
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
return;
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} catch (Throwable) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys for a customer, newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
$db = self::db();
|
||||
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
|
||||
if (!$includeRevoked) {
|
||||
$sql .= ' AND revoked_at IS NULL';
|
||||
}
|
||||
$sql .= ' ORDER BY id DESC';
|
||||
$stmt = $db->conn()->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $customerId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute list: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$stmt->close();
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key by id. Returns true if a row was removed.
|
||||
* Generally prefer `revoke()` over `delete()` so audit trails
|
||||
* stay intact.
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Schema bootstrap for the api_keys table.
|
||||
*
|
||||
* This codebase does NOT use a migration framework; new tables are
|
||||
* added via `*_schema_bootstrap.php` files that run idempotent
|
||||
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
|
||||
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
|
||||
* is the human-readable source of truth / change record.
|
||||
*/
|
||||
class api_key_schema_bootstrap
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
// No DB connection in this process (e.g. unit test) — skip.
|
||||
self::$initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
try {
|
||||
$db->query($query);
|
||||
} catch (\Throwable $e) {
|
||||
// Swallow on first-failure in unit-test contexts; the
|
||||
// migration companion file documents the canonical DDL.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tableExists(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name = 'api_keys'"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
||||
*/
|
||||
public function isValidFilePath(string $filePath): bool
|
||||
{
|
||||
// Check if the file path is valid
|
||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
||||
if (
|
||||
$filePath === ''
|
||||
|| str_starts_with($filePath, '/')
|
||||
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (explode('/', $filePath) as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
||||
{
|
||||
$host = 'https://api.truckwash.io';
|
||||
|
||||
$this->requireValidFilePath($fileName);
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||
|
||||
// Generate a direct download URL for the given file name
|
||||
return $host . '/files/' . $fileName;
|
||||
return $host . '/files/' . $encodedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace classes\auth;
|
||||
|
||||
/**
|
||||
* Scope registry: the source of truth for API key scopes and
|
||||
* role → scope defaults.
|
||||
*
|
||||
* This class is the canonical implementation that the parallel
|
||||
* `app\auth\Scope` stub (introduced by TRU-149 / branch
|
||||
* feat/TRU-149-route-scopes) will be replaced with once
|
||||
* `feat/api-key-foundation` is merged. Until then the two can
|
||||
* coexist; the middleware in `scope_middleware.php` continues
|
||||
* to use the legacy stub.
|
||||
*
|
||||
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
|
||||
* Two wildcard forms are recognised:
|
||||
* - `*` — matches every scope.
|
||||
* - `resource:*` — matches every action on a resource.
|
||||
*
|
||||
* Role defaults:
|
||||
* - superuser: every scope (via "*" wildcard).
|
||||
* - admin: customer:*, booking:*, subuser:*, invoice:*
|
||||
* - customer: customer:read, booking:read, invoice:read
|
||||
* - subuser: booking:read, booking:write
|
||||
*
|
||||
* The "self" / "assigned" qualifiers from the spec are *enforcement
|
||||
* layer* concerns, not scope concerns — they live in the resolver
|
||||
* that maps an authenticated principal to a customer/subuser record.
|
||||
* Scopes only encode "can the caller read bookings at all", not
|
||||
* "which bookings".
|
||||
*/
|
||||
final class scope_registry
|
||||
{
|
||||
// --- Customer resource ---
|
||||
public const CUSTOMER_READ = 'customer:read';
|
||||
public const CUSTOMER_WRITE = 'customer:write';
|
||||
|
||||
// --- Booking resource ---
|
||||
public const BOOKING_READ = 'booking:read';
|
||||
public const BOOKING_WRITE = 'booking:write';
|
||||
|
||||
// --- Subuser resource ---
|
||||
public const SUBUSER_READ = 'subuser:read';
|
||||
public const SUBUSER_WRITE = 'subuser:write';
|
||||
|
||||
// --- Invoice resource ---
|
||||
public const INVOICE_READ = 'invoice:read';
|
||||
public const INVOICE_WRITE = 'invoice:write';
|
||||
|
||||
// --- Superuser / admin resource ---
|
||||
public const SUPERUSER_READ = 'superuser:read';
|
||||
public const SUPERUSER_WRITE = 'superuser:write';
|
||||
|
||||
/**
|
||||
* Canonical list of every concrete scope (no wildcards).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default scope set carried by a role. Wildcards are
|
||||
* returned as-is; resolve them with `expand()` before checking
|
||||
* membership if you need a flat list.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function scopesForRole(string $role): array
|
||||
{
|
||||
switch (strtolower(trim($role))) {
|
||||
case 'superuser':
|
||||
return ['*'];
|
||||
case 'admin':
|
||||
return [
|
||||
'customer:*',
|
||||
'booking:*',
|
||||
'subuser:*',
|
||||
'invoice:*',
|
||||
];
|
||||
case 'customer':
|
||||
return [
|
||||
self::CUSTOMER_READ,
|
||||
self::BOOKING_READ,
|
||||
self::INVOICE_READ,
|
||||
];
|
||||
case 'subuser':
|
||||
return [
|
||||
self::BOOKING_READ,
|
||||
self::BOOKING_WRITE,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the granted scope (or wildcard) match the required scope?
|
||||
*
|
||||
* - "*" matches anything.
|
||||
* - "customer:*" matches "customer:read" and "customer:write".
|
||||
* - "customer:read" matches itself exactly.
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
*/
|
||||
public static function hasScope(array $granted, string $required): bool
|
||||
{
|
||||
$required = trim($required);
|
||||
if ($required === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ($granted as $candidate) {
|
||||
if (!is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (self::matches($candidate, $required)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of scopes (which may include wildcards) into the
|
||||
* full set of concrete scopes they grant. Useful for showing a
|
||||
* user what their key can do, or for caching decisions.
|
||||
*
|
||||
* The wildcard "*" expands to the full `all()` set. A wildcard
|
||||
* like "customer:*" expands to every concrete scope starting with
|
||||
* "customer:". Duplicate entries are removed.
|
||||
*
|
||||
* @param array<int, string> $scopes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $scopes): array
|
||||
{
|
||||
$concrete = self::all();
|
||||
$expanded = [];
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
if (!is_string($scope)) {
|
||||
continue;
|
||||
}
|
||||
$scope = trim($scope);
|
||||
if ($scope === '') {
|
||||
continue;
|
||||
}
|
||||
if ($scope === '*') {
|
||||
$expanded = array_merge($expanded, $concrete);
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2) . ':';
|
||||
foreach ($concrete as $candidate) {
|
||||
if (str_starts_with($candidate, $prefix)) {
|
||||
$expanded[] = $candidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Already concrete — pass through if it looks canonical.
|
||||
if (in_array($scope, $concrete, true)) {
|
||||
$expanded[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal wildcard matcher — public for testing.
|
||||
*/
|
||||
public static function matches(string $granted, string $required): bool
|
||||
{
|
||||
$granted = trim($granted);
|
||||
$required = trim($required);
|
||||
if ($granted === '' || $required === '') {
|
||||
return false;
|
||||
}
|
||||
if ($granted === '*') {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($granted, ':*')) {
|
||||
$prefix = substr($granted, 0, -2);
|
||||
return str_starts_with($required, $prefix . ':');
|
||||
}
|
||||
return $granted === $required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a scope string. Returns true iff the value is either
|
||||
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
|
||||
* for a known resource.
|
||||
*/
|
||||
public static function isValid(string $scope): bool
|
||||
{
|
||||
$scope = trim($scope);
|
||||
if ($scope === '' || $scope === '*') {
|
||||
return $scope !== '';
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2);
|
||||
foreach (self::all() as $concrete) {
|
||||
if (str_starts_with($concrete, $prefix . ':')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return in_array($scope, self::all(), true);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/account_deletion_service.php';
|
||||
|
||||
use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
@@ -69,6 +71,10 @@ 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);
|
||||
@@ -100,6 +106,9 @@ 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;
|
||||
@@ -107,6 +116,9 @@ 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
|
||||
@@ -116,6 +128,9 @@ 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
|
||||
@@ -123,13 +138,26 @@ 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 && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||
return true;
|
||||
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
|
||||
return !account_deletion_service::principalIsBlocked(
|
||||
'customer',
|
||||
(int)$dbToken->user_id->value()
|
||||
);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore and continue to subuser session validation
|
||||
@@ -137,7 +165,7 @@ class authentication implements authentication_i
|
||||
// Fallback: try validating as a subuser session token
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
if ($subuser !== null) {
|
||||
return true;
|
||||
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +196,10 @@ class authentication implements authentication_i
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
@@ -177,6 +208,11 @@ 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
|
||||
@@ -227,6 +263,9 @@ 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'];
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class backup_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_records (
|
||||
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
description TEXT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
|
||||
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
|
||||
storage_prefix VARCHAR(255) NOT NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
manifest_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
component_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
requested_by_user_id INT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
verified_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_records_status_created (status, created_at),
|
||||
KEY idx_backup_records_verified (verified_at),
|
||||
KEY idx_backup_records_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_components (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
component_type VARCHAR(32) NOT NULL,
|
||||
logical_name VARCHAR(191) NOT NULL,
|
||||
source_bucket VARCHAR(191) NULL,
|
||||
source_prefix VARCHAR(255) NULL,
|
||||
storage_key VARCHAR(255) NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content_sha256 CHAR(64) NULL,
|
||||
encrypted_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_components_backup (backup_uuid),
|
||||
KEY idx_backup_components_status (status),
|
||||
KEY idx_backup_components_type_name (component_type, logical_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(32) NOT NULL,
|
||||
backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
progress_message VARCHAR(255) NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
locked_at DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_jobs_status_created (status, created_at),
|
||||
KEY idx_backup_jobs_backup (backup_uuid),
|
||||
KEY idx_backup_jobs_type_status (job_type, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
restore_job_id BIGINT UNSIGNED NULL,
|
||||
preview_job_id BIGINT UNSIGNED NULL,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
|
||||
confirmation_fingerprint CHAR(64) NULL,
|
||||
reason TEXT NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
pre_restore_backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_restore_audit_backup (backup_uuid),
|
||||
KEY idx_backup_restore_audit_job (restore_job_id),
|
||||
KEY idx_backup_restore_audit_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -212,19 +212,7 @@ 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,
|
||||
@@ -1073,4 +1061,3 @@ class bird implements bird_i
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listApplications(): array
|
||||
{
|
||||
return $this->request('GET', '/applications');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
@@ -169,6 +174,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
@@ -179,6 +189,11 @@ class coolify_api_client
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function deleteApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace classes;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/cors_policy.php';
|
||||
|
||||
class coolify_manager
|
||||
{
|
||||
private const KINDS = ['database', 'redis', 'minio'];
|
||||
@@ -1070,7 +1072,9 @@ class coolify_manager
|
||||
$targetPublicUrl,
|
||||
$resourceUuid,
|
||||
self::resourceFirstExposedPort($resource, $target),
|
||||
$resource['custom_labels'] ?? null
|
||||
$resource['custom_labels'] ?? null,
|
||||
$app,
|
||||
self::gatewayRouteTargetCorsConfig($target)
|
||||
);
|
||||
$update = $resourceType === 'service'
|
||||
? $client->updateService($resourceUuid, $updatePayload)
|
||||
@@ -2420,7 +2424,9 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid = '',
|
||||
?int $port = null,
|
||||
mixed $existingLabels = null
|
||||
mixed $existingLabels = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
): array
|
||||
{
|
||||
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
|
||||
@@ -2435,7 +2441,9 @@ class coolify_manager
|
||||
$publicUrl,
|
||||
$resourceUuid,
|
||||
$routePort,
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl)
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl),
|
||||
$app,
|
||||
$corsConfig
|
||||
);
|
||||
if ($labels !== []) {
|
||||
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
||||
@@ -2460,6 +2468,49 @@ 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) {
|
||||
@@ -2483,7 +2534,9 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid,
|
||||
?int $port = null,
|
||||
?string $certResolver = null
|
||||
?string $certResolver = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
): array
|
||||
{
|
||||
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
|
||||
@@ -2508,6 +2561,7 @@ 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',
|
||||
@@ -2521,12 +2575,18 @@ 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}";
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
||||
} else {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
||||
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
|
||||
}
|
||||
$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,6 +29,7 @@ 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
|
||||
@@ -38,7 +39,7 @@ class cors_policy
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ class cors_policy
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -58,6 +59,27 @@ 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>
|
||||
*/
|
||||
@@ -66,6 +88,39 @@ 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>
|
||||
*/
|
||||
@@ -105,8 +160,8 @@ class cors_policy
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
if ($origin === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -119,7 +174,7 @@ class cors_policy
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_schedule
|
||||
{
|
||||
public static function normalize(array $schedule): array
|
||||
{
|
||||
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
|
||||
if ($type !== 'interval') {
|
||||
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
|
||||
}
|
||||
|
||||
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
|
||||
if ($seconds < 30 || $seconds > 2678400) {
|
||||
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'interval',
|
||||
'seconds' => $seconds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
|
||||
? strtotime($anchorDateTime)
|
||||
: false;
|
||||
$base = $anchor !== false ? (int)$anchor : $now;
|
||||
$next = $base + (int)$normalized['seconds'];
|
||||
|
||||
if ($next <= $now) {
|
||||
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
|
||||
$next += $missed * (int)$normalized['seconds'];
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $next);
|
||||
}
|
||||
|
||||
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
|
||||
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
|
||||
return self::nextRunAt($normalized, $lastRunAt, $now);
|
||||
}
|
||||
|
||||
if ($legacyLastRun !== null && $legacyLastRun > 0) {
|
||||
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class cron_scheduler
|
||||
{
|
||||
private cron_task_registry $registry;
|
||||
private string $lock_owner;
|
||||
|
||||
public function __construct(?cron_task_registry $registry = null)
|
||||
{
|
||||
$this->registry = $registry ?? new cron_task_registry();
|
||||
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
public function listTasks(): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$states = $this->stateRows();
|
||||
$estimates = $this->durationEstimates();
|
||||
$tasks = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
|
||||
? $state['schedule']
|
||||
: $definition->schedule;
|
||||
$nextRunAt = $state['next_run_at'] ?? null;
|
||||
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
|
||||
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
|
||||
}
|
||||
|
||||
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
|
||||
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
|
||||
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
|
||||
$tasks[] = $task;
|
||||
}
|
||||
|
||||
return [
|
||||
'tasks' => $tasks,
|
||||
'summary' => [
|
||||
'total' => count($tasks),
|
||||
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
|
||||
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function listRuns(?string $task_id = null, int $limit = 50): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
$where = '';
|
||||
if ($task_id !== null && trim($task_id) !== '') {
|
||||
$where = "WHERE task_id = " . $this->sql($task_id);
|
||||
}
|
||||
|
||||
return $this->fetchAll(
|
||||
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
|
||||
);
|
||||
}
|
||||
|
||||
public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
if ($this->taskIsLocked($state)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$existing = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_runs
|
||||
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
if ($existing !== null) {
|
||||
$this->markTaskQueued($definition);
|
||||
return $this->publicRun($existing);
|
||||
}
|
||||
|
||||
$scheduled_for = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ", 'manual', 'queued', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->sql($scheduled_for) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
$run_id = (int)$this->insertId();
|
||||
$this->markTaskQueued($definition);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function runDue(string $source = 'automatic'): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$ran = [];
|
||||
foreach ($this->queuedRuns() as $queuedRun) {
|
||||
try {
|
||||
$run = $this->runQueuedRun($queuedRun);
|
||||
if ($run !== null) {
|
||||
$ran[] = $run;
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => (string)($queuedRun['task_id'] ?? ''),
|
||||
'module' => (string)($queuedRun['module'] ?? ''),
|
||||
'source' => (string)($queuedRun['source'] ?? 'manual'),
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$states = $this->stateRows();
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nextRunAt = (string)($state['next_run_at'] ?? '');
|
||||
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => $definition->id,
|
||||
'module' => $definition->module,
|
||||
'source' => $source,
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ran' => $ran,
|
||||
'count' => count($ran),
|
||||
];
|
||||
}
|
||||
|
||||
public function markExpiredRunningRuns(): int
|
||||
{
|
||||
$this->ensureReady();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$message = 'Task lock expired before completion.';
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs r
|
||||
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
|
||||
SET r.status = 'timed_out',
|
||||
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
|
||||
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
|
||||
s.current_run_id = NULL,
|
||||
s.locked_until = NULL,
|
||||
s.lock_owner = NULL,
|
||||
s.last_status = 'timed_out',
|
||||
s.last_error = " . $this->sql($message) . "
|
||||
WHERE r.status = 'running'
|
||||
AND s.locked_until IS NOT NULL
|
||||
AND s.locked_until < " . $this->sql($now)
|
||||
);
|
||||
|
||||
return $this->affectedRows();
|
||||
}
|
||||
|
||||
public function runTask(
|
||||
string $task_id_or_legacy_name,
|
||||
string $source = 'manual',
|
||||
?int $actor_user_id = null,
|
||||
bool $force = false,
|
||||
?string $scheduled_for = null
|
||||
): array {
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
|
||||
$this->query(
|
||||
"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);
|
||||
}
|
||||
|
||||
private function executeClaimedRun(
|
||||
cron_task_definition $definition,
|
||||
int $run_id,
|
||||
float $started,
|
||||
?string $scheduled_for = null
|
||||
): array
|
||||
{
|
||||
$status = 'succeeded';
|
||||
$summary = [];
|
||||
$error_message = null;
|
||||
$output = '';
|
||||
|
||||
try {
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($definition->timeout_seconds + 30);
|
||||
}
|
||||
|
||||
$this->ensureLegacyFunctionsLoaded($definition);
|
||||
if (!is_callable($definition->handler)) {
|
||||
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$result = call_user_func($definition->handler);
|
||||
$output = (string)ob_get_clean();
|
||||
$summary = is_array($result) ? $result : [];
|
||||
} catch (Throwable $throwable) {
|
||||
if (ob_get_level() > 0) {
|
||||
$output .= (string)ob_get_clean();
|
||||
}
|
||||
$status = 'failed';
|
||||
$error_message = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$completed = microtime(true);
|
||||
$duration_ms = (int)round(($completed - $started) * 1000);
|
||||
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
|
||||
$status = 'timed_out';
|
||||
$error_message = 'Task exceeded its configured timeout window.';
|
||||
}
|
||||
|
||||
if ($output !== '') {
|
||||
$summary['output'] = substr($output, 0, 8000);
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function updateTaskConfig(string $task_id, array $config): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if (array_key_exists('enabled', $config)) {
|
||||
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
|
||||
}
|
||||
|
||||
if (array_key_exists('schedule', $config)) {
|
||||
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
|
||||
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
|
||||
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
|
||||
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->listTasks();
|
||||
}
|
||||
|
||||
private function ensureReady(): void
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
private function syncDefinitions(): void
|
||||
{
|
||||
$now = time();
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$row = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
if ($row !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyLastRun = $this->legacyLastRun($definition);
|
||||
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. ($definition->enabled ? '1' : '0') . ', NULL, '
|
||||
. $this->sql($nextRunAt)
|
||||
. ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function legacyLastRun(cron_task_definition $definition): ?int
|
||||
{
|
||||
if ($definition->legacy_name === null || !defined('redis')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$last_run = redis->get_last_crond_run($definition->legacy_name);
|
||||
return $last_run !== null ? (int)$last_run : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function claimLock(cron_task_definition $definition): bool
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = " . $this->sql($locked_until) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
|
||||
);
|
||||
|
||||
return $this->affectedRows() === 1;
|
||||
}
|
||||
|
||||
private function taskIsLocked(array $state): bool
|
||||
{
|
||||
$lockedUntil = (string)($state['locked_until'] ?? '');
|
||||
return $lockedUntil !== ''
|
||||
&& strtotime($lockedUntil) !== false
|
||||
&& strtotime($lockedUntil) >= time();
|
||||
}
|
||||
|
||||
private function markTaskQueued(cron_task_definition $definition): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'queued',
|
||||
last_error = NULL,
|
||||
next_run_at = CASE
|
||||
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
|
||||
ELSE next_run_at
|
||||
END
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function queuedRuns(): array
|
||||
{
|
||||
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
|
||||
}
|
||||
|
||||
private function runQueuedRun(array $queuedRun): ?array
|
||||
{
|
||||
$run_id = (int)($queuedRun['id'] ?? 0);
|
||||
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
|
||||
if ($run_id < 1 || $definition === null) {
|
||||
if ($run_id > 0) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task not found.');
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$force = (bool)($queuedRun['force_run'] ?? false);
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'running',
|
||||
started_at = " . $this->sql($started_at) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
|
||||
if ($this->affectedRows() !== 1) {
|
||||
$this->clearClaimedLock($definition);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"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
|
||||
);
|
||||
}
|
||||
|
||||
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
|
||||
{
|
||||
$completed_at = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'skipped',
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = 0,
|
||||
error_message = " . $this->sql($message) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
$updated = $this->affectedRows() === 1;
|
||||
if (!$updated || $definition === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'skipped',
|
||||
last_error = " . $this->sql($message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
private function clearClaimedLock(cron_task_definition $definition): void
|
||||
{
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = NULL,
|
||||
lock_owner = NULL
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
}
|
||||
|
||||
private function releaseLock(
|
||||
cron_task_definition $definition,
|
||||
string $status,
|
||||
?string $error_message,
|
||||
string $completed_at,
|
||||
?string $scheduled_for = null
|
||||
): 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);
|
||||
if ($schedule === []) {
|
||||
$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());
|
||||
if ($status !== 'succeeded') {
|
||||
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
|
||||
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_run_at = " . $this->sql($completed_at) . ",
|
||||
next_run_at = " . $this->sql($nextRunAt) . ",
|
||||
locked_until = NULL,
|
||||
lock_owner = NULL,
|
||||
current_run_id = NULL,
|
||||
last_status = " . $this->sql($status) . ",
|
||||
last_error = " . $this->nullableSql($error_message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
|
||||
if ($definition->legacy_name !== null && defined('redis')) {
|
||||
try {
|
||||
redis->set_last_crond_run($definition->legacy_name, time());
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function createRun(
|
||||
cron_task_definition $definition,
|
||||
string $source,
|
||||
?int $actor_user_id,
|
||||
?string $scheduled_for,
|
||||
string $started_at,
|
||||
bool $force = false
|
||||
): int {
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. $this->sql($source) . ", 'running', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->nullableSql($scheduled_for) . ', '
|
||||
. $this->sql($started_at) . ', '
|
||||
. $this->sql($this->lock_owner) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
return $this->insertId();
|
||||
}
|
||||
|
||||
private function completeRun(
|
||||
int $run_id,
|
||||
string $status,
|
||||
string $completed_at,
|
||||
int $duration_ms,
|
||||
array $summary,
|
||||
?string $error_message
|
||||
): void {
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = " . $this->sql($status) . ",
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = " . (string)$duration_ms . ",
|
||||
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
|
||||
error_message = " . $this->nullableSql($error_message) . "
|
||||
WHERE id = " . (string)$run_id
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
|
||||
{
|
||||
if (function_exists($definition->handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('WD')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
|
||||
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
|
||||
}
|
||||
|
||||
require_once WD . '/cron/Cron.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function stateRows(): array
|
||||
{
|
||||
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
|
||||
$states = [];
|
||||
foreach ($rows as $row) {
|
||||
$row['enabled'] = (bool)$row['enabled'];
|
||||
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
|
||||
$states[(string)$row['task_id']] = $row;
|
||||
}
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function durationEstimates(): array
|
||||
{
|
||||
$rows = $this->fetchAll(
|
||||
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
|
||||
FROM (
|
||||
SELECT task_id, duration_ms
|
||||
FROM cron_task_runs
|
||||
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
) recent_runs
|
||||
GROUP BY task_id"
|
||||
);
|
||||
|
||||
$estimates = [];
|
||||
foreach ($rows as $row) {
|
||||
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
|
||||
}
|
||||
return $estimates;
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $json): array
|
||||
{
|
||||
if (!is_string($json) || trim($json) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function publicRun(array $run): array
|
||||
{
|
||||
if ($run === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$run['force_run'] = (bool)($run['force_run'] ?? false);
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function fetchOne(string $sql): ?array
|
||||
{
|
||||
$rows = $this->fetchAll($sql);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
return $value === null ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function affectedRows(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->conn()->affected_rows;
|
||||
}
|
||||
|
||||
private function insertId(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->insert_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cron_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_state (
|
||||
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
schedule_json LONGTEXT NULL,
|
||||
last_run_at DATETIME NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
locked_until DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
current_run_id BIGINT UNSIGNED NULL,
|
||||
last_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_state_next_run (enabled, next_run_at),
|
||||
KEY idx_cron_task_state_lock (locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_runs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(191) NOT NULL,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
actor_user_id INT NULL,
|
||||
scheduled_for DATETIME NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
duration_ms INT UNSIGNED NULL,
|
||||
summary_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
force_run TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_runs_task_created (task_id, created_at),
|
||||
KEY idx_cron_task_runs_status_created (status, created_at),
|
||||
KEY idx_cron_task_runs_module_created (module, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
self::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_worker_state (
|
||||
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
hostname VARCHAR(191) NULL,
|
||||
pid INT UNSIGNED NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'starting',
|
||||
release_channel_id BIGINT UNSIGNED NULL,
|
||||
release_target_id BIGINT UNSIGNED NULL,
|
||||
coolify_resource_uuid VARCHAR(128) NULL,
|
||||
coolify_resource_type VARCHAR(32) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
|
||||
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
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,
|
||||
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
|
||||
KEY idx_cron_worker_state_status (status),
|
||||
KEY idx_cron_worker_state_release_target (release_target_id),
|
||||
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;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_definition
|
||||
{
|
||||
public string $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public string $module;
|
||||
public string $handler;
|
||||
public array $schedule;
|
||||
public bool $enabled;
|
||||
public int $timeout_seconds;
|
||||
public int $estimated_duration_ms;
|
||||
public int $priority;
|
||||
public ?string $legacy_name;
|
||||
|
||||
public function __construct(array $definition)
|
||||
{
|
||||
$this->id = self::requiredString($definition, 'id');
|
||||
$this->name = self::requiredString($definition, 'name');
|
||||
$this->description = (string)($definition['description'] ?? '');
|
||||
$this->module = self::requiredString($definition, 'module');
|
||||
$this->handler = self::requiredString($definition, 'handler');
|
||||
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
|
||||
$this->enabled = (bool)($definition['enabled'] ?? true);
|
||||
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
|
||||
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
|
||||
$this->priority = (int)($definition['priority'] ?? 100);
|
||||
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
|
||||
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
|
||||
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
|
||||
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
|
||||
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
|
||||
}
|
||||
}
|
||||
|
||||
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
|
||||
{
|
||||
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
|
||||
? $state['schedule']
|
||||
: $this->schedule;
|
||||
$enabled = array_key_exists('enabled', $state ?? [])
|
||||
? (bool)$state['enabled']
|
||||
: $this->enabled;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'module' => $this->module,
|
||||
'handler' => $this->handler,
|
||||
'schedule' => $schedule,
|
||||
'default_schedule' => $this->schedule,
|
||||
'enabled' => $enabled,
|
||||
'default_enabled' => $this->enabled,
|
||||
'timeout_seconds' => $this->timeout_seconds,
|
||||
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
|
||||
'priority' => $this->priority,
|
||||
'legacy_name' => $this->legacy_name,
|
||||
'last_run_at' => $state['last_run_at'] ?? null,
|
||||
'next_run_at' => $state['next_run_at'] ?? null,
|
||||
'locked_until' => $state['locked_until'] ?? null,
|
||||
'lock_owner' => $state['lock_owner'] ?? null,
|
||||
'current_run_id' => $state['current_run_id'] ?? null,
|
||||
'last_status' => $state['last_status'] ?? null,
|
||||
'last_error' => $state['last_error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function requiredString(array $definition, string $key): string
|
||||
{
|
||||
$value = trim((string)($definition[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_registry
|
||||
{
|
||||
private string $modules_root;
|
||||
|
||||
/** @var array<string, cron_task_definition>|null */
|
||||
private ?array $definitions = null;
|
||||
|
||||
public function __construct(?string $modules_root = null)
|
||||
{
|
||||
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, cron_task_definition>
|
||||
*/
|
||||
public function definitions(): array
|
||||
{
|
||||
if ($this->definitions !== null) {
|
||||
return $this->definitions;
|
||||
}
|
||||
|
||||
$definitions = [];
|
||||
foreach ($this->definitionFiles() as $file) {
|
||||
$module_definitions = require $file;
|
||||
if (!is_array($module_definitions)) {
|
||||
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
|
||||
}
|
||||
|
||||
foreach ($module_definitions as $definition) {
|
||||
$task = new cron_task_definition($definition);
|
||||
if (isset($definitions[$task->id])) {
|
||||
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
|
||||
}
|
||||
$definitions[$task->id] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
|
||||
if ($left->priority !== $right->priority) {
|
||||
return $left->priority <=> $right->priority;
|
||||
}
|
||||
return strcmp($left->id, $right->id);
|
||||
});
|
||||
|
||||
$this->definitions = $definitions;
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
public function get(string $id_or_legacy_name): ?cron_task_definition
|
||||
{
|
||||
$normalized = trim($id_or_legacy_name);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definitions = $this->definitions();
|
||||
if (isset($definitions[$normalized])) {
|
||||
return $definitions[$normalized];
|
||||
}
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function definitionFiles(): array
|
||||
{
|
||||
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
private string $source;
|
||||
private int $poll_seconds;
|
||||
private int $heartbeat_seconds;
|
||||
private int $max_runtime_seconds;
|
||||
private bool $should_stop = false;
|
||||
private int $last_heartbeat = 0;
|
||||
|
||||
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
|
||||
{
|
||||
$this->scheduler = $scheduler ?? new cron_scheduler();
|
||||
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
|
||||
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
|
||||
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
|
||||
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
|
||||
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
|
||||
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
|
||||
$this->heartbeat('disabled', 0, 0, null, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
$started = time();
|
||||
$this->heartbeat('starting', 0, 0, null, true);
|
||||
|
||||
while (!$this->should_stop) {
|
||||
$pollStarted = microtime(true);
|
||||
$result = $this->tick();
|
||||
$this->writeStatusLine($result);
|
||||
|
||||
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
|
||||
$this->should_stop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$this->sleepUntilNextPoll($pollStarted + $this->poll_seconds);
|
||||
}
|
||||
|
||||
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function tick(): array
|
||||
{
|
||||
$this->heartbeat('running');
|
||||
$loopStartedAt = date('Y-m-d H:i:s');
|
||||
$staleRuns = 0;
|
||||
$ran = ['count' => 0, 'ran' => []];
|
||||
$error = null;
|
||||
$status = 'running';
|
||||
|
||||
try {
|
||||
$staleRuns = $this->scheduler->markExpiredRunningRuns();
|
||||
$ran = $this->scheduler->runDue($this->source);
|
||||
} catch (Throwable $throwable) {
|
||||
$status = 'failed';
|
||||
$error = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
|
||||
|
||||
return [
|
||||
'worker_id' => $this->worker_id,
|
||||
'status' => $status,
|
||||
'ran' => (int)($ran['count'] ?? 0),
|
||||
'stale_runs' => $staleRuns,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function listWorkers(): array
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
|
||||
$workers = [];
|
||||
foreach ($rows as $row) {
|
||||
$workers[] = $this->publicWorker($row);
|
||||
}
|
||||
|
||||
return [
|
||||
'workers' => $workers,
|
||||
'summary' => [
|
||||
'total' => count($workers),
|
||||
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
|
||||
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (!function_exists('pcntl_signal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
}
|
||||
|
||||
pcntl_signal(SIGTERM, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
pcntl_signal(SIGINT, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
}
|
||||
|
||||
private function sleepUntilNextPoll(float $nextPollAt): void
|
||||
{
|
||||
while (!$this->should_stop) {
|
||||
$remaining = $nextPollAt - microtime(true);
|
||||
if ($remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
usleep((int)(min(1.0, $remaining) * 1000000));
|
||||
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||
$this->heartbeat('running');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function heartbeat(
|
||||
string $status,
|
||||
int $runCount = 0,
|
||||
int $staleRunCount = 0,
|
||||
?string $error = null,
|
||||
bool $force = false,
|
||||
bool $stopped = false,
|
||||
?string $loopStartedAt = null
|
||||
): void {
|
||||
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$this->last_heartbeat = time();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$workerId = $this->sql($this->worker_id);
|
||||
$name = $this->sql($this->name);
|
||||
$hostname = $this->nullableSql(gethostname() ?: null);
|
||||
$pid = getmypid() ?: 0;
|
||||
$source = $this->sql($this->source);
|
||||
$statusSql = $this->sql($status);
|
||||
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
|
||||
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
|
||||
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
|
||||
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
|
||||
$commitSha = $this->nullableSql($this->commitSha());
|
||||
$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
|
||||
) 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
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
hostname = VALUES(hostname),
|
||||
pid = VALUES(pid),
|
||||
source = VALUES(source),
|
||||
status = VALUES(status),
|
||||
release_channel_id = VALUES(release_channel_id),
|
||||
release_target_id = VALUES(release_target_id),
|
||||
coolify_resource_uuid = VALUES(coolify_resource_uuid),
|
||||
coolify_resource_type = VALUES(coolify_resource_type),
|
||||
commit_sha = VALUES(commit_sha),
|
||||
poll_seconds = VALUES(poll_seconds),
|
||||
last_run_count = VALUES(last_run_count),
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
||||
private function publicWorker(array $row): array
|
||||
{
|
||||
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
|
||||
$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'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'hostname' => $row['hostname'] ?? null,
|
||||
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
|
||||
'source' => (string)($row['source'] ?? ''),
|
||||
'status' => (string)($row['status'] ?? 'unknown'),
|
||||
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
|
||||
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
|
||||
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
|
||||
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
|
||||
'commit_sha' => $row['commit_sha'] ?? null,
|
||||
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
|
||||
'last_run_count' => (int)($row['last_run_count'] ?? 0),
|
||||
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
|
||||
'last_error' => $row['last_error'] ?? null,
|
||||
'started_at' => $row['started_at'] ?? null,
|
||||
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
private function writeStatusLine(array $result): void
|
||||
{
|
||||
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
|
||||
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
. PHP_EOL;
|
||||
}
|
||||
|
||||
private function stringOption(array $options, string $key, string $env, string $default): string
|
||||
{
|
||||
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
|
||||
return $value !== '' ? $value : $default;
|
||||
}
|
||||
|
||||
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private function boolOption(string $env, bool $default): bool
|
||||
{
|
||||
$value = $this->env($env);
|
||||
if ($value === null || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return self::normalizeBoolean($value);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
{
|
||||
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
|
||||
$value = trim((string)($this->env($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function env(string $key): ?string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableInt(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
return (string)max(0, (int)$value);
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
$value = $value !== null ? trim($value) : '';
|
||||
return $value === '' ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for the customer `invoice_email` field
|
||||
* (TRU-77 / DRIFT 16). The field is optional and stores an
|
||||
* e-mail address that should receive the customer's invoices
|
||||
* separately from the customer's primary `email`.
|
||||
*/
|
||||
class customer_invoice_email_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private const TABLE = 'users';
|
||||
private const COLUMN = 'invoice_email';
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureUsersTable($db);
|
||||
self::ensureInvoiceEmailColumn($db);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureUsersTable(object $db): void
|
||||
{
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_number INT NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
email VARCHAR(255) NULL,
|
||||
phone_country_code INT NULL,
|
||||
phone BIGINT NULL,
|
||||
password VARCHAR(255) NULL,
|
||||
group_id INT NOT NULL DEFAULT 0,
|
||||
xlvask_customer_id VARCHAR(255) NULL,
|
||||
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
wash_certificate_email VARCHAR(255) NULL,
|
||||
invoice_email VARCHAR(255) NULL,
|
||||
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
two_factor_secret VARCHAR(255) NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
KEY idx_users_customer_number (customer_number),
|
||||
KEY idx_users_group_id (group_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
}
|
||||
|
||||
private static function ensureInvoiceEmailColumn(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, self::TABLE)) {
|
||||
return;
|
||||
}
|
||||
if (self::columnExists($db, self::TABLE, self::COLUMN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$safeTable = str_replace('`', '', self::TABLE);
|
||||
$db->query(
|
||||
"ALTER TABLE `{$safeTable}`
|
||||
ADD COLUMN " . self::COLUMN . " VARCHAR(255) NULL
|
||||
AFTER wash_certificate_email"
|
||||
);
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = str_replace("'", '', $column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ class customer_mass_import_service
|
||||
*/
|
||||
public function import(array $payload): array
|
||||
{
|
||||
// TRU-77 / DRIFT 16: ensure the invoice_email column exists before we
|
||||
// attempt to populate it on a local customer.
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
|
||||
$normalized = $this->normalizePayload($payload);
|
||||
$this->assertValidNormalizedPayload($normalized);
|
||||
|
||||
@@ -62,9 +66,15 @@ class customer_mass_import_service
|
||||
}
|
||||
|
||||
$normalized['name'] = $this->resolveCreateName($normalized);
|
||||
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
|
||||
// TRU-77 / DRIFT 16: resolve the e-conomic delivery address into a
|
||||
// local variable instead of overwriting $normalized['email']. The
|
||||
// primary customer email must remain intact for the result payload
|
||||
// and for downstream local-customer sync; the create call needs the
|
||||
// dedicated invoice address (or the primary as a fallback) on its
|
||||
// own.
|
||||
$createEmail = $this->resolveCreateEmail($normalized, $warnings);
|
||||
|
||||
$createResponse = $this->createEconomicCustomer($normalized);
|
||||
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
|
||||
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
|
||||
|
||||
if ($createdCustomerNumber !== $customerNumber) {
|
||||
@@ -111,6 +121,7 @@ class customer_mass_import_service
|
||||
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
|
||||
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
|
||||
'email' => $this->normalizeEmail($payload['email'] ?? null),
|
||||
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
|
||||
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
|
||||
];
|
||||
}
|
||||
@@ -137,6 +148,10 @@ class customer_mass_import_service
|
||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||
}
|
||||
|
||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizePositiveInt(mixed $value): ?int
|
||||
@@ -189,6 +204,42 @@ class customer_mass_import_service
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the optional dedicated invoice email (TRU-77 / DRIFT 16).
|
||||
* Empty/whitespace values collapse to null. An explicit non-empty value
|
||||
* must be a syntactically valid email address; an invalid value is
|
||||
* rejected to keep invoices from being routed to a malformed address.
|
||||
*/
|
||||
protected function normalizeInvoiceEmail(mixed $value): ?string
|
||||
{
|
||||
$email = $this->normalizeText($value);
|
||||
if ($email === null) {
|
||||
return null;
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \RuntimeException('Invalid invoice email address.', 400);
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the e-mail address that e-conomic should use to deliver
|
||||
* invoices for the customer (TRU-77 / DRIFT 16). Prefers the dedicated
|
||||
* `invoice_email` when provided, falling back to the customer's primary
|
||||
* `email`.
|
||||
*/
|
||||
protected function resolveInvoiceEmail(array $normalized, array &$warnings): string
|
||||
{
|
||||
if (!empty($normalized['invoice_email'])) {
|
||||
return (string)$normalized['invoice_email'];
|
||||
}
|
||||
if (!empty($normalized['email'])) {
|
||||
return (string)$normalized['email'];
|
||||
}
|
||||
$warnings[] = 'No invoice email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
|
||||
return 'jb@truckwash.dk';
|
||||
}
|
||||
|
||||
protected function resolveCreateName(array $normalized): string
|
||||
{
|
||||
if ($normalized['name'] !== null) {
|
||||
@@ -205,12 +256,9 @@ class customer_mass_import_service
|
||||
|
||||
protected function resolveCreateEmail(array $normalized, array &$warnings): string
|
||||
{
|
||||
if ($normalized['email'] !== null) {
|
||||
return $normalized['email'];
|
||||
}
|
||||
|
||||
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
|
||||
return 'jb@truckwash.dk';
|
||||
// TRU-77 / DRIFT 16: invoices must be routed to the dedicated
|
||||
// invoice_email when provided, otherwise to the customer's email.
|
||||
return $this->resolveInvoiceEmail($normalized, $warnings);
|
||||
}
|
||||
|
||||
protected function searchEconomicCustomersByCvr(string $cvr): array
|
||||
@@ -225,7 +273,7 @@ class customer_mass_import_service
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
protected function createEconomicCustomer(array $normalized): object
|
||||
protected function createEconomicCustomer(array $normalized, string $createEmail): object
|
||||
{
|
||||
$payload = [
|
||||
'customerNumber' => (int)$normalized['customer_number'],
|
||||
@@ -237,7 +285,10 @@ class customer_mass_import_service
|
||||
'paymentTermsNumber' => 12,
|
||||
],
|
||||
'name' => (string)$normalized['name'],
|
||||
'email' => (string)$normalized['email'],
|
||||
// TRU-77 / DRIFT 16: the dedicated invoice_email (or the
|
||||
// primary email as a fallback) is passed in explicitly so the
|
||||
// caller's $normalized['email'] is never mutated here.
|
||||
'email' => $createEmail,
|
||||
'phone' => (int)$normalized['phone'],
|
||||
'telephoneAndFaxNumber' => (string)$normalized['phone'],
|
||||
'mobilePhone' => (string)$normalized['phone'],
|
||||
@@ -392,6 +443,7 @@ class customer_mass_import_service
|
||||
'cvr' => (string)$normalized['cvr'],
|
||||
'name' => $customerName,
|
||||
'email' => $normalized['email'],
|
||||
'invoice_email' => $normalized['invoice_email'] ?? null,
|
||||
'ean' => $normalized['ean'],
|
||||
'action' => $action,
|
||||
'message' => $message,
|
||||
@@ -412,6 +464,7 @@ class customer_mass_import_service
|
||||
|
||||
$name = $normalized['name'] ?? null;
|
||||
$email = $normalized['email'] ?? null;
|
||||
$invoice_email = $normalized['invoice_email'] ?? null;
|
||||
$phone = $normalized['phone'] ?? null;
|
||||
|
||||
$displayName = trim((string)($customer->display_name->value() ?? ''));
|
||||
@@ -427,6 +480,16 @@ class customer_mass_import_service
|
||||
}
|
||||
}
|
||||
|
||||
// TRU-77 / DRIFT 16: persist the dedicated invoice email override
|
||||
// when provided so invoice routing survives subsequent local edits.
|
||||
if ($invoice_email !== null && $customer->getInvoiceEmailOverride() === null) {
|
||||
try {
|
||||
$customer->setInvoiceEmail($invoice_email);
|
||||
} catch (\Throwable $throwable) {
|
||||
$warnings[] = 'Unable to update local invoice email: ' . $throwable->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($phone !== null && empty($customer->phone->value())) {
|
||||
try {
|
||||
$customer->setPhoneNumber((int)$phone);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||
if ($customerNumber === null) {
|
||||
return null;
|
||||
}
|
||||
$violation = (new customer_rule_product_restriction_service())
|
||||
->violationForCustomerProduct($customerNumber, $productId);
|
||||
return $violation === null ? null : (string)$violation['message'];
|
||||
}
|
||||
|
||||
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||
return $customerNumber > 0 ? $customerNumber : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
/**
|
||||
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||
(int)$order->customer_id->value(),
|
||||
$productId
|
||||
);
|
||||
if ($violation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the singular key during the API migration for existing invoice
|
||||
// and logging consumers while also returning every matching rule.
|
||||
return ['rule' => (string)$violation['rules'][0]] + $violation;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Additive schema and the one-time legacy-to-exact-product migration for
|
||||
* customer-rule product restrictions.
|
||||
*/
|
||||
class customer_rule_product_restriction_schema_bootstrap
|
||||
{
|
||||
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const RULES = [
|
||||
'restrictAdditionalServices' => 'Additional services',
|
||||
'restrictTankCleaning' => 'Tank cleaning',
|
||||
'restrictSpotFree' => 'SpotFree',
|
||||
'restrictInteriorCleaning' => 'Interior cleaning',
|
||||
'onlyTankCleaning' => 'Non-tank products',
|
||||
];
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::createTables($db);
|
||||
self::deduplicateCustomerAttributes($db);
|
||||
self::seedLegacyProductSets($db);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function createTables(object $db): void
|
||||
{
|
||||
$statements = [
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
version INT UNSIGNED 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 (attribute)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
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_customer_rule_collection_name (attribute, name),
|
||||
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
|
||||
CONSTRAINT fk_customer_rule_collection_attribute
|
||||
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
|
||||
collection_id INT UNSIGNED NOT NULL,
|
||||
product_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (collection_id, product_id),
|
||||
KEY idx_customer_rule_collection_product (product_id, collection_id),
|
||||
CONSTRAINT fk_customer_rule_collection_product_collection
|
||||
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
|
||||
migration_key VARCHAR(191) NOT NULL,
|
||||
details_json LONGTEXT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (migration_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_user_id INT UNSIGNED NULL,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
old_version INT UNSIGNED NOT NULL,
|
||||
new_version INT UNSIGNED NOT NULL,
|
||||
changes_json LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
|
||||
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if ($db->query($statement) === false) {
|
||||
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function deduplicateCustomerAttributes(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'customer_attributes')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'DELETE duplicate_row FROM customer_attributes duplicate_row
|
||||
INNER JOIN customer_attributes keep_row
|
||||
ON keep_row.user_id = duplicate_row.user_id
|
||||
AND keep_row.attribute = duplicate_row.attribute
|
||||
AND keep_row.id < duplicate_row.id'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to deduplicate customer attributes');
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'ALTER TABLE customer_attributes
|
||||
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to enforce unique customer attributes');
|
||||
}
|
||||
}
|
||||
|
||||
private static function seedLegacyProductSets(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
|
||||
$existing = $db->query(
|
||||
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
|
||||
);
|
||||
if ($existing && (int)$existing->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new RuntimeException('Unable to start customer-rule product migration');
|
||||
}
|
||||
try {
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
|
||||
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to claim customer-rule product migration');
|
||||
}
|
||||
if (self::affectedRows($db) === 0) {
|
||||
$db->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_keys(self::RULES) as $attribute) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||
VALUES ('{$safeAttribute}', 1)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to initialize restriction {$attribute}");
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
$seededProductIds = [];
|
||||
foreach (self::RULES as $attribute => $collectionName) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
if ($collectionId < 1) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
|
||||
$predicate = self::legacyPredicate($db, $attribute);
|
||||
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
|
||||
? 'p.deleted_at IS NULL'
|
||||
: '1 = 1';
|
||||
$insert = $db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
SELECT {$collectionId}, p.id
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
WHERE ({$activePredicate}) AND ({$predicate})"
|
||||
);
|
||||
if ($insert === false) {
|
||||
throw new RuntimeException("Unable to seed products for {$attribute}");
|
||||
}
|
||||
$counts[$attribute] = self::affectedRows($db);
|
||||
$seeded = $db->query(
|
||||
"SELECT product_id FROM customer_rule_product_collection_products
|
||||
WHERE collection_id = {$collectionId} ORDER BY product_id"
|
||||
);
|
||||
$seededProductIds[$attribute] = [];
|
||||
if ($seeded) {
|
||||
while ($row = $seeded->fetch_assoc()) {
|
||||
$seededProductIds[$attribute][] = (int)$row['product_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$details = self::escape($db, (string)json_encode([
|
||||
'counts' => $counts,
|
||||
'product_ids' => $seededProductIds,
|
||||
'seeded_at' => gmdate(DATE_ATOM),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_migrations
|
||||
SET details_json = '{$details}', applied_at = NOW()
|
||||
WHERE migration_key = '{$migrationKey}'"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to record customer-rule product migration');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new RuntimeException('Unable to commit customer-rule product migration');
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private static function legacyPredicate(object $db, string $attribute): string
|
||||
{
|
||||
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
|
||||
|
||||
return match ($attribute) {
|
||||
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
|
||||
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
|
||||
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
|
||||
: ''),
|
||||
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
|
||||
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
|
||||
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
|
||||
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
|
||||
default => '0 = 1',
|
||||
};
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$safeTable = self::escape($db, $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeIndex = self::escape($db, $index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = self::escape($db, $column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escape(object $db, string $value): string
|
||||
{
|
||||
return method_exists($db, 'escape_string')
|
||||
? $db->escape_string($value)
|
||||
: addslashes($value);
|
||||
}
|
||||
|
||||
private static function affectedRows(object $db): int
|
||||
{
|
||||
if (method_exists($db, 'conn')) {
|
||||
$connection = $db->conn();
|
||||
return (int)($connection->affected_rows ?? 0);
|
||||
}
|
||||
return (int)($db->affected_rows ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class customer_rule_product_restriction_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->restrictionCode = $code;
|
||||
}
|
||||
|
||||
private string $restrictionCode;
|
||||
|
||||
public function httpStatus(): int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
|
||||
public function restrictionCode(): string
|
||||
{
|
||||
return $this->restrictionCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for globally configured customer-rule product collections.
|
||||
*/
|
||||
class customer_rule_product_restriction_service
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const PRODUCT_IMPACT_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
public const SUPPORTED_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
'requiresReferenceNumber',
|
||||
'requiresRegistrationNumbersInvoice',
|
||||
'invoiceAllOrdersIndividually',
|
||||
'invoiceWithStripe',
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
|
||||
public function listConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
|
||||
'products' => $this->productCatalog(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function ruleConfiguration(string $attribute): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
global $db;
|
||||
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
|
||||
FROM customer_rule_product_collections c
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE c.attribute = '{$safeAttribute}'
|
||||
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
|
||||
}
|
||||
|
||||
$collections = [];
|
||||
$disabled = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$collectionId = (int)$row['collection_id'];
|
||||
if (!isset($collections[$collectionId])) {
|
||||
$collections[$collectionId] = [
|
||||
'id' => $collectionId,
|
||||
'name' => (string)$row['name'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'product_ids' => [],
|
||||
];
|
||||
}
|
||||
if ($row['product_id'] !== null) {
|
||||
$productId = (int)$row['product_id'];
|
||||
$collections[$collectionId]['product_ids'][] = $productId;
|
||||
$disabled[$productId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'attribute' => $attribute,
|
||||
'version' => max(1, (int)($versionRow['version'] ?? 1)),
|
||||
'collections' => array_values($collections),
|
||||
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
|
||||
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
|
||||
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
try {
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions
|
||||
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
$currentVersion = (int)$versionRow['version'];
|
||||
if ($currentVersion !== $expectedVersion) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Customer rule configuration has changed; reload before saving',
|
||||
409,
|
||||
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
|
||||
);
|
||||
}
|
||||
|
||||
$old = $this->ruleConfiguration($attribute);
|
||||
$existingIds = $this->existingCollectionIds($attribute);
|
||||
foreach ($collections as $collection) {
|
||||
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
|
||||
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid temporary unique-name collisions while two collections swap names.
|
||||
foreach ($existingIds as $collectionId => $_) {
|
||||
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$temporaryName}'
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($collections as $collection) {
|
||||
$name = $this->escape($collection['name']);
|
||||
$sortOrder = (int)$collection['sort_order'];
|
||||
$collectionId = $collection['id'];
|
||||
if ($collectionId === null) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to create collection');
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
} else {
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$name}', sort_order = {$sortOrder}
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update collection');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds[$collectionId] = true;
|
||||
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to save collection products');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
|
||||
if ($removeIds !== []) {
|
||||
if ($db->query(
|
||||
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
|
||||
implode(',', array_map('intval', $removeIds)) . ')'
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $currentVersion + 1;
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_restrictions
|
||||
SET version = {$newVersion}, updated_at = NOW()
|
||||
WHERE attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
$new = $this->ruleConfiguration($attribute);
|
||||
$changes = $this->escape((string)json_encode([
|
||||
'before' => $old,
|
||||
'after' => $new,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_audit_logs
|
||||
(actor_user_id, attribute, old_version, new_version, changes_json)
|
||||
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
return $new;
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return configured product restrictions for all active product-impact
|
||||
* attributes belonging to any account with the customer number.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function restrictionsForCustomerNumber(int $customerNumber): array
|
||||
{
|
||||
if ($customerNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT ca.attribute
|
||||
FROM users u
|
||||
INNER JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
WHERE u.customer_number = {$customerNumber}"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load active customer-rule product restrictions');
|
||||
}
|
||||
$activeAttributes = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attribute = (string)$row['attribute'];
|
||||
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$activeAttributes[$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$active = [];
|
||||
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
|
||||
if (isset($activeAttributes[$attribute])) {
|
||||
$active[] = $this->ruleConfiguration($attribute);
|
||||
}
|
||||
}
|
||||
return $active;
|
||||
}
|
||||
|
||||
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
|
||||
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
|
||||
{
|
||||
if ($productId < 1) {
|
||||
return null;
|
||||
}
|
||||
$rules = [];
|
||||
$collections = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
|
||||
continue;
|
||||
}
|
||||
$rules[] = (string)$restriction['attribute'];
|
||||
foreach ($restriction['collections'] as $collection) {
|
||||
if (in_array($productId, $collection['product_ids'], true)) {
|
||||
$collections[] = (int)$collection['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rules === []) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
|
||||
'message' => customer_product_rule_service::BLOCK_MESSAGE,
|
||||
'product_id' => $productId,
|
||||
'rules' => array_values(array_unique($rules)),
|
||||
'collections' => array_values(array_unique($collections)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $attributes
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function enrichAttributes(int $customerNumber, array $attributes): array
|
||||
{
|
||||
$restrictions = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
$restrictions[(string)$restriction['attribute']] = [
|
||||
'attribute' => (string)$restriction['attribute'],
|
||||
'version' => (int)$restriction['version'],
|
||||
'collections' => $restriction['collections'],
|
||||
'disabled_product_ids' => $restriction['disabled_product_ids'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($attributes as &$attribute) {
|
||||
$key = (string)($attribute['attribute'] ?? '');
|
||||
$attribute['product_restriction'] = $restrictions[$key] ?? null;
|
||||
}
|
||||
unset($attribute);
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function productCatalog(): array
|
||||
{
|
||||
global $db;
|
||||
$activeExpression = $this->columnExists('products', 'deleted_at')
|
||||
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
|
||||
: '1';
|
||||
$result = $db->query(
|
||||
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
|
||||
{$activeExpression} AS active
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
ORDER BY c.name ASC, p.name ASC, p.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load the customer-rule product catalog');
|
||||
}
|
||||
$products = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$products[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'category_id' => (int)$row['category_id'],
|
||||
'category_name' => (string)($row['category_name'] ?? ''),
|
||||
'active' => (bool)$row['active'],
|
||||
];
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,true>
|
||||
*/
|
||||
private function existingCollectionIds(string $attribute): array
|
||||
{
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load existing collections for {$attribute}");
|
||||
}
|
||||
$ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ids[(int)$row['id']] = true;
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @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)) {
|
||||
throw new customer_rule_product_restriction_exception('collections must be an array');
|
||||
}
|
||||
$normalized = [];
|
||||
$names = [];
|
||||
$collectionIds = [];
|
||||
$allProductIds = [];
|
||||
foreach (array_values($value) as $index => $collection) {
|
||||
if (!is_array($collection)) {
|
||||
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
|
||||
}
|
||||
$name = trim((string)($collection['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 191) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
|
||||
}
|
||||
$nameKey = mb_strtolower($name);
|
||||
if (isset($names[$nameKey])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
|
||||
}
|
||||
$names[$nameKey] = true;
|
||||
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
|
||||
throw new customer_rule_product_restriction_exception('product_ids must be an array');
|
||||
}
|
||||
$productIds = [];
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
$id = $this->positiveInt($productId, 'product_id');
|
||||
$productIds[$id] = true;
|
||||
$allProductIds[$id] = true;
|
||||
}
|
||||
$id = isset($collection['id']) && $collection['id'] !== null
|
||||
? $this->positiveInt($collection['id'], 'collection id')
|
||||
: null;
|
||||
if ($id !== null && isset($collectionIds[$id])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
|
||||
}
|
||||
if ($id !== null) {
|
||||
$collectionIds[$id] = true;
|
||||
}
|
||||
$normalized[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
|
||||
? (int)$collection['sort_order']
|
||||
: $index,
|
||||
'product_ids' => array_map('intval', array_keys($productIds)),
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<int> $productIds */
|
||||
private function assertProductsExist(array $productIds): void
|
||||
{
|
||||
if ($productIds === []) {
|
||||
return;
|
||||
}
|
||||
global $db;
|
||||
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
|
||||
if (!$result) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Unable to validate collection products',
|
||||
500,
|
||||
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
$found = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$found[(int)$row['id']] = true;
|
||||
}
|
||||
$missing = array_values(array_diff($productIds, array_keys($found)));
|
||||
if ($missing !== []) {
|
||||
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSupportedAttribute(string $attribute): void
|
||||
{
|
||||
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
private function positiveInt(mixed $value, string $field): int
|
||||
{
|
||||
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
|
||||
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = $this->escape($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,19 @@ class db
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function getSslMode(): string
|
||||
{
|
||||
return $this->ssl_mode;
|
||||
}
|
||||
|
||||
public function backupDatabase(string $path): bool
|
||||
{
|
||||
// Save the database to the path
|
||||
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
|
||||
$mode = strtoupper(trim($this->ssl_mode));
|
||||
// Map ssl_mode to MariaDB client flags
|
||||
// DISABLED => --skip-ssl (no TLS)
|
||||
// PREFERRED => (no flag; client decides)
|
||||
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
|
||||
$sslFlag = '';
|
||||
switch ($mode) {
|
||||
case 'DISABLED':
|
||||
@@ -201,17 +205,42 @@ class db
|
||||
$sslFlag = '--ssl';
|
||||
break;
|
||||
}
|
||||
|
||||
$host = escapeshellarg($this->host);
|
||||
$user = escapeshellarg($this->user);
|
||||
$pass = escapeshellarg($this->password);
|
||||
$db = escapeshellarg($this->database);
|
||||
$port = (int)$this->port;
|
||||
$outfile = escapeshellarg($path);
|
||||
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
||||
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
|
||||
exec($command, $output, $return);
|
||||
// Check if the command was successful
|
||||
return $return === 0;
|
||||
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$environment = array_merge(getenv() ?: [], $_ENV);
|
||||
$environment['MYSQL_PWD'] = $this->password;
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $path, 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
||||
if (!is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$return = proc_close($process);
|
||||
|
||||
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
|
||||
@file_put_contents($path . '.error.log', $stderr);
|
||||
}
|
||||
|
||||
return $return === 0 && is_file($path) && filesize($path) !== false;
|
||||
}
|
||||
|
||||
public function getView(string $view): array
|
||||
@@ -238,4 +267,4 @@ class db
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for department-scoped customer price overrides.
|
||||
*/
|
||||
class department_customer_price_overrides_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`product_or_category_id` VARCHAR(191) NOT NULL,
|
||||
`percentage` INT NOT NULL DEFAULT 0,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
|
||||
KEY `idx_department_customer_price_overrides_department` (`department_id`),
|
||||
KEY `idx_department_customer_price_overrides_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\department_customer_price_overrides_o;
|
||||
use objects\departments_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class department_customer_pricing_service
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPricing(int $departmentId, int $userId): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
|
||||
|
||||
return [
|
||||
'department' => $department,
|
||||
'customer' => $customer,
|
||||
'overrides' => $overrides,
|
||||
'categories' => $this->catalog($departmentId, $customer['id']),
|
||||
'revision' => $this->revision($overrides),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePricing(int $departmentId, int $userId, array $payload): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
|
||||
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
|
||||
}
|
||||
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
|
||||
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
|
||||
}
|
||||
|
||||
$overrides = $payload['overrides'] ?? null;
|
||||
if (!is_array($overrides)) {
|
||||
throw new limited_backoffice_exception('Overrides are required.', 400);
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
||||
$overrideObject = new department_customer_price_overrides_o();
|
||||
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
|
||||
$existingOverrides = [];
|
||||
$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();
|
||||
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` = ?'
|
||||
);
|
||||
$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',
|
||||
$departmentId,
|
||||
$customerId,
|
||||
$isCategory,
|
||||
$objectId,
|
||||
$percentage,
|
||||
$fixedPrice
|
||||
);
|
||||
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;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
||||
}
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$this->recordVersion($customer, $departmentId, $override);
|
||||
}
|
||||
|
||||
foreach ($existingOverrides as $existingOverride) {
|
||||
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
|
||||
if (isset($normalizedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->recordVersion($customer, $departmentId, [
|
||||
'is_category' => (bool)$existingOverride['is_category'],
|
||||
'product_or_category_id' => $existingOverride['product_or_category_id'],
|
||||
'percentage' => 0,
|
||||
'fixed_price' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->getPricing($departmentId, $customer['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||
*/
|
||||
private function department(int $departmentId): array
|
||||
{
|
||||
$department = (new departments_o())->getDepartmentById($departmentId);
|
||||
if (!is_array($department) || empty($department)) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'description' => (string)($department['description'] ?? ''),
|
||||
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,customer_number:int,display_name:string}
|
||||
*/
|
||||
private function customer(int $userId): array
|
||||
{
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
if (!$customer->exists()) {
|
||||
throw new limited_backoffice_exception('Customer not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$customer->id,
|
||||
'customer_number' => (int)$customer->customer_number->value(),
|
||||
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $department
|
||||
*/
|
||||
private function assertEnabled(array $department): void
|
||||
{
|
||||
if (!($department['custom_pricing_only'] ?? false)) {
|
||||
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
|
||||
'message' => 'Department customer pricing is disabled.',
|
||||
'code' => 'department_customer_pricing_disabled',
|
||||
'department' => $department,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function catalog(int $departmentId, int $userId): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
c.`id` AS `category_id`,
|
||||
c.`name` AS `category_name`,
|
||||
c.`description` AS `category_description`,
|
||||
p.*,
|
||||
pdp.`price` AS `department_price`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
LEFT JOIN `product_department_prices` pdp
|
||||
ON pdp.`department_id` = dc.`department_id`
|
||||
AND pdp.`product_id` = p.`id`
|
||||
WHERE dc.`department_id` = " . (int)$departmentId . "
|
||||
AND dc.`deleted_at` IS NULL
|
||||
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $db->fetch_all($result) : [];
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
$categories = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$productId = (int)$row['id'];
|
||||
if (isset($seen[$productId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$productId] = true;
|
||||
|
||||
$categoryId = (int)$row['category_id'];
|
||||
if (!isset($categories[$categoryId])) {
|
||||
$categories[$categoryId] = [
|
||||
'id' => $categoryId,
|
||||
'name' => (string)$row['category_name'],
|
||||
'description' => (string)($row['category_description'] ?? ''),
|
||||
'products' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
|
||||
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
if ($departmentPrice !== null) {
|
||||
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
|
||||
}
|
||||
|
||||
$categories[$categoryId]['products'][] = [
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['name'],
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'category' => $categoryId,
|
||||
'apply_category_discount' => (bool)$row['apply_category_discount'],
|
||||
'base_price' => (int)$row['price'],
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_price' => $effectivePrice,
|
||||
'missing_department_price' => $departmentPrice === null,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $overrides
|
||||
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
|
||||
*/
|
||||
private function normalizeOverrides(int $departmentId, array $overrides): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($overrides as $override) {
|
||||
if (!is_array($override)) {
|
||||
throw new limited_backoffice_exception('Invalid override payload.', 400);
|
||||
}
|
||||
|
||||
$isCategory = (bool)($override['is_category'] ?? false);
|
||||
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
|
||||
if ($objectId === null || $objectId === '') {
|
||||
throw new limited_backoffice_exception('Override object is required.', 400);
|
||||
}
|
||||
|
||||
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($percentage === false || $percentage < 0 || $percentage > 100) {
|
||||
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
|
||||
}
|
||||
|
||||
$fixedPrice = null;
|
||||
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
|
||||
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
|
||||
if ($fixedPrice === false || $fixedPrice < 0) {
|
||||
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
$this->assertDepartmentCategory($departmentId, $objectId);
|
||||
}
|
||||
} else {
|
||||
$objectId = (int)$objectId;
|
||||
$this->assertDepartmentProduct($departmentId, $objectId);
|
||||
}
|
||||
|
||||
if ($percentage <= 0 && $fixedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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,
|
||||
'percentage' => (int)$percentage,
|
||||
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,customer_number:int,display_name:string} $customer
|
||||
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
|
||||
*/
|
||||
private function recordVersion(array $customer, int $departmentId, array $override): void
|
||||
{
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
|
||||
(int)$customer['id'],
|
||||
(int)$customer['customer_number'],
|
||||
(bool)$override['is_category'],
|
||||
(string)$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
date('Y-m-d H:i:s'),
|
||||
'live.department_discount_override.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => 'department_customer_pricing',
|
||||
'department_id' => $departmentId,
|
||||
],
|
||||
$override['fixed_price'],
|
||||
$departmentId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function overrideKey(bool $isCategory, int|string $objectId): string
|
||||
{
|
||||
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;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT p.`id`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
WHERE dc.`department_id` = ' . (int)$departmentId . '
|
||||
AND dc.`deleted_at` IS NULL
|
||||
AND p.`id` = ' . (int)$productId . '
|
||||
LIMIT 1'
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Product is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$categoryId = $db->escape_string($categoryId);
|
||||
$result = $db->query(
|
||||
"SELECT `id`
|
||||
FROM `department_categories`
|
||||
WHERE `department_id` = " . (int)$departmentId . "
|
||||
AND `deleted_at` IS NULL
|
||||
AND `category_id` = '{$categoryId}'
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Category is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
|
||||
use Exception;
|
||||
|
||||
class department_wash_count_service
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT deduped.department_id,
|
||||
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
COUNT(*) AS wash_count
|
||||
FROM (
|
||||
SELECT dedupe_key,
|
||||
department_id,
|
||||
MIN(counted_at) AS counted_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
) deduped
|
||||
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
|
||||
ORDER BY deduped.department_id ASC, hour_bucket ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
|
||||
'wash_count' => (int)($row['wash_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array{quantity:int,products:int,earnings:int,washes:int}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [
|
||||
'quantity' => 0,
|
||||
'products' => 0,
|
||||
'earnings' => 0,
|
||||
'washes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
|
||||
COALESCE(SUM(oi.quantity), 0) AS products,
|
||||
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
||||
|
||||
return [
|
||||
'quantity' => (int)($row['quantity'] ?? 0),
|
||||
'products' => (int)($row['products'] ?? 0),
|
||||
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
|
||||
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{id:int,department_id:int,created_at:string}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
|
||||
department_id,
|
||||
MIN(counted_at) AS created_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
ORDER BY created_at ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $department_ids
|
||||
* @throws Exception
|
||||
*/
|
||||
private function countRows(string $date_start, string $date_end, array $department_ids): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
|
||||
{
|
||||
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
|
||||
o.id AS entity_id,
|
||||
o.department_id,
|
||||
o.created_at AS counted_at,
|
||||
0 AS source_priority
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL
|
||||
AND p.is_wash = 1
|
||||
UNION ALL
|
||||
SELECT CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
|
||||
ELSE CONCAT('selfserve:', s.id)
|
||||
END AS dedupe_key,
|
||||
CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN linked_o.id
|
||||
ELSE s.id
|
||||
END AS entity_id,
|
||||
COALESCE(linked_o.department_id, s.department_id) AS department_id,
|
||||
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
|
||||
1 AS source_priority
|
||||
FROM selfserve_wash_sessions s
|
||||
LEFT JOIN orders linked_o
|
||||
ON linked_o.id = s.order_id
|
||||
AND linked_o.deleted_at IS NULL
|
||||
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
|
||||
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.completed_at IS NOT NULL
|
||||
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $ids
|
||||
* @return array<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
$value = (int)$id;
|
||||
if ($value > 0) {
|
||||
$normalized[$value] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateDateRange(string $date_start, string $date_end): void
|
||||
{
|
||||
if (strtotime($date_start) === false || strtotime($date_end) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($date_start) > strtotime($date_end)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -172,7 +172,8 @@ class economic implements economic_i
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
object|array|null $company_information = null,
|
||||
?string $ean = null
|
||||
): object
|
||||
{
|
||||
$payload = [
|
||||
@@ -196,10 +197,37 @@ class economic implements economic_i
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
||||
if ($normalized_ean !== null) {
|
||||
$payload['ean'] = $normalized_ean;
|
||||
}
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
public static function normalizeCustomerEan(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
||||
if (!is_string($digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = trim($digits);
|
||||
if ($digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($digits) > 13) {
|
||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Centralized selection of e-conomic invoice layout numbers.
|
||||
*
|
||||
* This class is the **skeleton** introduced by TRU-197. It exposes the two
|
||||
* layout numbers that the backend should use for the two invoice variants:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
|
||||
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
|
||||
*
|
||||
* The constants below are placeholders for the layout numbers that the
|
||||
* e-conomic account admin must pick in e-conomic (Settings → Design and
|
||||
* Layouts) and write into the module-config DB variables
|
||||
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
|
||||
* themselves are intentionally left as `0` in this skeleton — they are
|
||||
* resolved at runtime from the module-config variables by the two existing
|
||||
* call sites:
|
||||
*
|
||||
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
|
||||
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
|
||||
*
|
||||
* Wiring those call sites to read from this selector (instead of from the
|
||||
* module-config variables directly) is intentionally **out of scope** for
|
||||
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
|
||||
* full audit and follow-up plan.
|
||||
*
|
||||
* Constants in this class are the *single source of truth* for the
|
||||
* env-var-style aliases:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
|
||||
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
|
||||
*/
|
||||
class economic_layout_selector
|
||||
{
|
||||
/**
|
||||
* Layout number for invoices WITHOUT itemized discount lines.
|
||||
*
|
||||
* Intent: a clean invoice — no "Rabat" line, no discount column, just
|
||||
* the line items and totals.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Layout number for invoices WITH itemized discount lines.
|
||||
*
|
||||
* Intent: an invoice that visibly itemizes the negative `Rabat`
|
||||
* (product `TotDiscount`) line so the customer can see the discount
|
||||
* broken out instead of folded into per-product `discountPercentage`.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITH_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Module-config variable name for the without-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
|
||||
|
||||
/**
|
||||
* Module-config variable name for the with-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithoutDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||
);
|
||||
|
||||
return $collected_order_invoices->asArray();
|
||||
$result = $collected_order_invoices->asArray();
|
||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||
if ($transfer_metrics !== null) {
|
||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,23 +328,21 @@ 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,
|
||||
0,
|
||||
$discount_percentage,
|
||||
(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")) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user