Compare commits
317
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975909b6a1 | ||
|
|
1468e43ce2 | ||
|
|
ee2af5091c | ||
|
|
0672a68e8b | ||
|
|
c8804bc8dc | ||
|
|
b92d1f0bdf | ||
|
|
cc10371346 | ||
|
|
ac60596218 | ||
|
|
f4b9d71d40 | ||
|
|
77b1c8ec78 | ||
|
|
01221d8282 | ||
|
|
47068e6d7e | ||
|
|
46bdeded78 | ||
|
|
eefa521fc5 | ||
|
|
ec1988715d | ||
|
|
c3fb2e8651 | ||
|
|
0fb279fc5f | ||
|
|
3e970d9cb9 | ||
|
|
8c10c07cc9 | ||
|
|
18a8513b40 | ||
|
|
4c77b78c6c | ||
|
|
bb249da477 | ||
|
|
eb16a4e6ce | ||
|
|
a2e525fa9e | ||
|
|
0bf19c9d33 | ||
|
|
5850bfbce7 | ||
|
|
fd51a5b119 | ||
|
|
140365c8bb | ||
|
|
c9ceac8533 | ||
|
|
4266b933f5 | ||
|
|
72ec62d042 | ||
|
|
d24b50f751 | ||
|
|
21f5e6d9cf | ||
|
|
73b91ccec9 | ||
|
|
0c809a19da | ||
|
|
3a6685c345 | ||
|
|
a60983f328 | ||
|
|
e2c2eb21cb | ||
|
|
51c619b0c6 | ||
|
|
fe9daf1bf2 | ||
|
|
9c2d7140b4 | ||
|
|
1505464095 | ||
|
|
cc00fb2aed | ||
|
|
7f38cf2f7e | ||
|
|
d281dddbc1 | ||
|
|
267ec1bed1 | ||
|
|
a96f40cf13 | ||
|
|
d6190626ce | ||
|
|
ce8e6d0dab | ||
|
|
c13c2e2cab | ||
|
|
7380bc729b | ||
|
|
434a5049e2 | ||
|
|
6489706231 | ||
|
|
eb66b343ea | ||
|
|
e363f27da9 | ||
|
|
fbad5f767f | ||
|
|
94d9b347bf | ||
|
|
76744fd6c3 | ||
|
|
f5c1a34c29 | ||
|
|
22ad96bc8e | ||
|
|
8a749cffa3 | ||
|
|
cf5cf8d5eb | ||
|
|
eb14b7039b | ||
|
|
f09b1263c1 | ||
|
|
9ec8499d55 | ||
|
|
8d40cd6f9a | ||
|
|
ccffad3c7c | ||
|
|
4697c6b272 | ||
|
|
45e17e196c | ||
|
|
dcc81cbdc7 | ||
|
|
c5cb0a3bfe | ||
|
|
465f3ed027 | ||
|
|
80ff01f04e | ||
|
|
f6e4d851d3 | ||
|
|
cd4e3faea3 | ||
|
|
4cb9e68b33 | ||
|
|
0e7e79d205 | ||
|
|
a9ca7b41a7 | ||
|
|
3b3ed31bb7 | ||
|
|
cf9d5875ef | ||
|
|
4730eebdb4 | ||
|
|
b25ce9cb11 | ||
|
|
fdb98f1399 | ||
|
|
1dc758a3a3 | ||
|
|
032ce93d5e | ||
|
|
f8ced3b8f2 | ||
|
|
a81e239de8 | ||
|
|
9ea5a62577 | ||
|
|
af89a246db | ||
|
|
20c1973565 | ||
|
|
3555904423 | ||
|
|
a2dda5ea5b | ||
|
|
ce29cf9ccb | ||
|
|
e7481297c8 | ||
|
|
e3b38519fb | ||
|
|
d244c000c3 | ||
|
|
dcd57c7092 | ||
|
|
0a7e58fc01 | ||
|
|
bd7deaeded | ||
|
|
07a3ef6418 | ||
|
|
bffed6f5f3 | ||
|
|
bfec31f94b | ||
|
|
2123835aae | ||
|
|
11f06e8f53 | ||
|
|
6712368323 | ||
|
|
99fe659dbc | ||
|
|
357cfda46e | ||
|
|
9c85135a07 | ||
|
|
a8a47104dd | ||
|
|
2c0907c486 | ||
|
|
b1647b4ad1 | ||
|
|
0ae28af309 | ||
|
|
64d7e6f061 | ||
|
|
4f9a10402b | ||
|
|
5e8ec85943 | ||
|
|
20d6056e40 | ||
|
|
5be6bc0198 | ||
|
|
373aa7effb | ||
|
|
5282ee10ba | ||
|
|
06cba73a30 | ||
|
|
eab8394579 | ||
|
|
b88c2742e8 | ||
|
|
4ea5eeb942 | ||
|
|
e41b226529 | ||
|
|
7cb248a112 | ||
|
|
dfa0441266 | ||
|
|
d9dbd7dede | ||
|
|
3b8463e37f | ||
|
|
0e8b527ee4 | ||
|
|
86fb8bb700 | ||
|
|
6fbf7f271d | ||
|
|
fe5ebdc203 | ||
|
|
c6dbc0728f | ||
|
|
a0b1dcb3e3 | ||
|
|
38c4c32f07 | ||
|
|
b6beb9622b | ||
|
|
db80dad15f | ||
|
|
cf370a8035 | ||
|
|
0778776f00 | ||
|
|
ee55c23cde | ||
|
|
1f50c83f93 | ||
|
|
85f7bd1fc9 | ||
|
|
8f53e80ede | ||
|
|
2a1a730a8c | ||
|
|
7bb67b0470 | ||
|
|
1065973b33 | ||
|
|
1d05550cd3 | ||
|
|
2cc12c23cd | ||
|
|
b09ada0bc4 | ||
|
|
43dfac836a | ||
|
|
d1871f1420 | ||
|
|
08a1538ed6 | ||
|
|
f2fc4f6f18 | ||
|
|
503fd50c61 | ||
|
|
1d6df82c1c | ||
|
|
3fda0f9912 | ||
|
|
decc571307 | ||
|
|
ef237b5e87 | ||
|
|
828c177a57 | ||
|
|
c79219eb00 | ||
|
|
6995c3d1bc | ||
|
|
7436584598 | ||
|
|
06421beb6b | ||
|
|
7de4b96074 | ||
|
|
ea69c64fad | ||
|
|
652b89d23d | ||
|
|
bc4b7bde15 | ||
|
|
a5b674286a | ||
|
|
18bf7aa013 | ||
|
|
bf8262b64f | ||
|
|
fdb073f17f | ||
|
|
20fcd4ac16 | ||
|
|
0f7d76d96d | ||
|
|
324f2c856f | ||
|
|
84f203939c | ||
|
|
368501a8ce | ||
|
|
d9a36e4050 | ||
|
|
a5019efbda | ||
|
|
b16a07fdbb | ||
|
|
ca02fd3436 | ||
|
|
65283b8ad7 | ||
|
|
ad53041bfd | ||
|
|
b11b38a95b | ||
|
|
ba9c4d3b9f | ||
|
|
ded497b3d8 | ||
|
|
2fd3ce4877 | ||
|
|
64fc70a0a8 | ||
|
|
42acf26ee1 | ||
|
|
fe6eae862f | ||
|
|
eedde6c6d7 | ||
|
|
2782afde2e | ||
|
|
484529660b | ||
|
|
fbe700a4db | ||
|
|
6225c4b072 | ||
|
|
300a37fce3 | ||
|
|
c43618351e | ||
|
|
b3225c8d8b | ||
|
|
0f96247bf3 | ||
|
|
4703e07951 | ||
|
|
7ddda9ab03 | ||
|
|
4e9575cd87 | ||
|
|
ef82a95feb | ||
|
|
fd4ec3dda2 | ||
|
|
1616bd431a | ||
|
|
334a7a4401 | ||
|
|
22dd9f9c07 | ||
|
|
5684da1bc7 | ||
|
|
69cd039322 | ||
|
|
0dc7f813a8 | ||
|
|
a828e9bc25 | ||
|
|
8d2e71aaf3 | ||
|
|
721e2670dd | ||
|
|
b03500d2d1 | ||
|
|
ed9ebc2ac8 | ||
|
|
64beb38bae | ||
|
|
e13bbae01f | ||
|
|
5ba0f5f9ba | ||
|
|
bb5f1db1b3 | ||
|
|
cb63d10415 | ||
|
|
4183c3928c | ||
|
|
28bae85b2a | ||
|
|
f2db92de09 | ||
|
|
a41334f513 | ||
|
|
175fb3a35f | ||
|
|
8e6b29810a | ||
|
|
21e9b2c80f | ||
|
|
989d04167a | ||
|
|
286127c390 | ||
|
|
2ba87a4850 | ||
|
|
6658af814b | ||
|
|
2abd6d04e9 | ||
|
|
3107779b74 | ||
|
|
61a09dce87 | ||
|
|
a02ed69108 | ||
|
|
9b69aadca4 | ||
|
|
6204fb50f9 | ||
|
|
0a6a8aeab2 | ||
|
|
7c21b6463d | ||
|
|
d97cfda0ea | ||
|
|
aad5d77f41 | ||
|
|
3b132cad95 | ||
|
|
ab957092bd | ||
|
|
b8f65f242f | ||
|
|
688cb0a664 | ||
|
|
6eb4171fea | ||
|
|
ddba27a1be | ||
|
|
933b18b988 | ||
|
|
18c6852865 | ||
|
|
77403965f8 | ||
|
|
a3e2765ad4 | ||
|
|
787db994dd | ||
|
|
f8f603a38e | ||
|
|
31a7224272 | ||
|
|
492c81e27c | ||
|
|
45bfb1525a | ||
|
|
71ffa20811 | ||
|
|
a466c6291c | ||
|
|
9606d3b11d | ||
|
|
03b7fcd1b1 | ||
|
|
3d0f0f3391 | ||
|
|
e3257465a0 | ||
|
|
0c21f6e3a1 | ||
|
|
f1e5cacd0c | ||
|
|
a8d5320ae5 | ||
|
|
95ac0d3a2c | ||
|
|
1ed27dd467 | ||
|
|
c4bb7bbb8b | ||
|
|
c09b7ebe76 | ||
|
|
166ed6b92b | ||
|
|
8e528f3eae | ||
|
|
160772b832 | ||
|
|
c8a5c3969d | ||
|
|
bb98df9e73 | ||
|
|
fe3719530a | ||
|
|
603f497bef | ||
|
|
ee16db8ecc | ||
|
|
c5c33d3cf7 | ||
|
|
da05c5adb7 | ||
|
|
707cf67d5c | ||
|
|
09fa186028 | ||
|
|
5e6b340f8c | ||
|
|
04e47a2e6d | ||
|
|
572f5027d6 | ||
|
|
235e0268c2 | ||
|
|
65d639853b | ||
|
|
e856bbffec | ||
|
|
3ee5b789ce | ||
|
|
7f5722ff75 | ||
|
|
50b596af39 | ||
|
|
af06c4d81e | ||
|
|
41ed692299 | ||
|
|
31214f0af0 | ||
|
|
aceaa6b957 | ||
|
|
cd0e0f0e61 | ||
|
|
0db6b5269d | ||
|
|
3fb1eb9644 | ||
|
|
76dfcd70d1 | ||
|
|
b13abe0d30 | ||
|
|
270e5b970f | ||
|
|
5dac3211ff | ||
|
|
4d91fc8ead | ||
|
|
893ed1bda5 | ||
|
|
90ebec84bf | ||
|
|
bdf2a787d6 | ||
|
|
f8c254607d | ||
|
|
20eb92891a | ||
|
|
4cfe906f55 | ||
|
|
184ea1ca6c | ||
|
|
ae3657e7aa | ||
|
|
54de2e5674 | ||
|
|
eef436d44b | ||
|
|
b7aeb11801 | ||
|
|
d52ceb8513 | ||
|
|
1504f1b116 | ||
|
|
78462f3ae4 | ||
|
|
6ff6ce9b48 | ||
|
|
bc7c0280f2 |
+7
-1
@@ -1 +1,7 @@
|
|||||||
/docker-compose.yml
|
/docker-compose.yml
|
||||||
|
|
||||||
|
# Runtime-generated replication bootstrap snapshots may contain infrastructure
|
||||||
|
# metadata and encrypted/plaintext credential material. They must be
|
||||||
|
# supplied at runtime via mounted storage, not baked into deployment images.
|
||||||
|
/services/nginx/app/storage/replication-bootstrap.json
|
||||||
|
/services/nginx/app/storage/replication-bootstrap-*.json
|
||||||
|
|||||||
+5
-2
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
|
|||||||
CONFIG_DB_PORT=3306
|
CONFIG_DB_PORT=3306
|
||||||
CONFIG_DB_SSL_MODE=DISABLED
|
CONFIG_DB_SSL_MODE=DISABLED
|
||||||
|
|
||||||
|
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
|
||||||
|
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
|
||||||
|
|
||||||
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
|
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
|
||||||
# Any blank debug value falls back to the live value above.
|
# Any blank debug value falls back to the live value above.
|
||||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||||
@@ -50,8 +53,8 @@ ECONOMIC_API_APP_SECRET_TOKEN=
|
|||||||
# Edge broker defaults for shell relay and gateway dispatch.
|
# Edge broker defaults for shell relay and gateway dispatch.
|
||||||
EDGE_BROKER_URL=http://edge-broker:4300
|
EDGE_BROKER_URL=http://edge-broker:4300
|
||||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||||
EDGE_AUTH_MODE=manager
|
EDGE_AUTH_MODE=strict
|
||||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev
|
EDGE_BROKER_SHARED_SECRET=
|
||||||
|
|
||||||
# Redis credentials
|
# Redis credentials
|
||||||
REDIS_CONFIG_HOST=redis
|
REDIS_CONFIG_HOST=redis
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
USE_ENV=true
|
|
||||||
# Target of the database connection. Can be either 'live' or 'debug'.
|
|
||||||
CONFIG_DB_TARGET=live
|
|
||||||
CONFIG_DB_DATABASE=nnks_db
|
|
||||||
#CONFIG_DB_HOST=94.130.142.41
|
|
||||||
CONFIG_DB_HOST=23.88.23.183
|
|
||||||
CONFIG_DB_PASSWORD=562X0Lrr7Cz6zpXZ11I
|
|
||||||
CONFIG_DB_USER=root
|
|
||||||
CONFIG_DB_PORT=5432
|
|
||||||
CONFIG_DB_DEBUG_DATABASE=nnks_db
|
|
||||||
CONFIG_DB_DEBUG_HOST=23.88.23.183
|
|
||||||
CONFIG_DB_DEBUG_PORT=5432
|
|
||||||
CONFIG_DB_DEBUG_PASSWORD=562X0Lrr7Cz6zpXZ11I
|
|
||||||
CONFIG_DB_DEBUG_USER=root
|
|
||||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
|
||||||
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
|
|
||||||
# CORS=*
|
|
||||||
|
|
||||||
DEBUG=false
|
|
||||||
ECONOMIC_API_APP_ACCESS_GRANT=94bhkmdtaDA7kVn9abF2SGDccBDMvk5a6iWYnmJMbvQ1
|
|
||||||
ECONOMIC_API_APP_ACCESS_GRANT2=qGSBSkh1pjBtdSOygHhaMPn1A4PcMto3sCDCGYpLmsg1
|
|
||||||
ECONOMIC_API_APP_SECRET_TOKEN=V8GSEcIxMsTISczzTTBbOAMJyh8eucGZtBiGOxjMFg0
|
|
||||||
EMAIL_WASH_CERTIFICATE_TOKEN=H7uDTtFaeN4asqpb5okh6dr8z209SGtt
|
|
||||||
ENCRYPTION_KEY=Gvm37uF2VyTOjGkVl4kjrGQ0qRwOyq9lr3+p/QyUDjc\\=
|
|
||||||
MINIO_ACCESS_KEY=d7u6RaFyYmckAIWYGUYr
|
|
||||||
MINIO_ENDPOINT=http://162.55.225.220:9000
|
|
||||||
MINIO_SECRET_KEY=a2wJUQfkOPNO3UJfXYIdpNq4r1RrthcjiUfW1gVS
|
|
||||||
REDIS_CONFIG_DATABASE=0
|
|
||||||
REDIS_CONFIG_HOST=23.88.23.183
|
|
||||||
REDIS_CONFIG_PASSWORD=BlVg5o1NwkkR1IjKxQm
|
|
||||||
REDIS_CONFIG_PORT=5433
|
|
||||||
REDIS_CONFIG_USER=default
|
|
||||||
REDIS_CONFIG_DEBUG_PORT=5433
|
|
||||||
REDIS_CONFIG_DEBUG_USER=default
|
|
||||||
SLACK_DEFAULT_WEBHOOK=https://hooks.slaCk.com/services/T05SRKWTX9C/B08AGMP459P/1W5JN1NpHsHlbHHM2WljpvrU
|
|
||||||
WORDPRESS_API_URL=https://www.truckwash.dk/wp-admin/admin-ajax.php
|
|
||||||
WORDPRESS_STATIC_TOKEN=earm8BX4MFTgS6JCNQdqW5EzHUutv2Vx
|
|
||||||
ELASTIC_APM_SERVER_URL=http://elastic-agent:8200
|
|
||||||
ELASTIC_APM_SECRET_TOKEN=apm_dev_token
|
|
||||||
ELASTIC_APM_SERVICE_NAME=api-truckwash
|
|
||||||
ELASTIC_APM_ENVIRONMENT=dev
|
|
||||||
AUTO_COMPOSER_INSTALL=false
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.webm binary
|
||||||
@@ -9,25 +9,44 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
qodana:
|
qodana:
|
||||||
# Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits.
|
# Use GitHub-hosted runners for PR scans so untrusted code never runs on persistent internal infrastructure.
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: read
|
||||||
checks: write
|
checks: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
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
|
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
|
- name: Prepare Qodana cache directories
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||||
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
||||||
|
- name: Detect Qodana Cloud token
|
||||||
|
id: qodana-token
|
||||||
|
env:
|
||||||
|
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "${QODANA_TOKEN:-}" ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 'Qodana Scan'
|
- name: 'Qodana Scan'
|
||||||
uses: JetBrains/qodana-action@v2025.3
|
if: ${{ steps.qodana-token.outputs.present == 'true' }}
|
||||||
|
uses: JetBrains/qodana-action@v2026.1
|
||||||
with:
|
with:
|
||||||
pr-mode: false
|
pr-mode: false
|
||||||
env:
|
env:
|
||||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||||
QODANA_ENDPOINT: 'https://qodana.cloud'
|
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."
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
php:
|
php:
|
||||||
name: PHP ${{ matrix.suite }} (required)
|
name: PHP ${{ matrix.suite }} (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
edge-agent:
|
edge-agent:
|
||||||
name: Edge Agent (required)
|
name: Edge Agent (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
|
|
||||||
edge-broker:
|
edge-broker:
|
||||||
name: Edge Broker (required)
|
name: Edge Broker (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -125,7 +125,7 @@ jobs:
|
|||||||
|
|
||||||
edge-gateway-backend:
|
edge-gateway-backend:
|
||||||
name: Edge Gateway Backend (required)
|
name: Edge Gateway Backend (required)
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
@@ -144,6 +144,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cp .github/ci.env .env
|
cp .github/ci.env .env
|
||||||
cp .github/ci.env.staging .env.staging
|
cp .github/ci.env.staging .env.staging
|
||||||
|
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -151,7 +152,7 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Boot local stack
|
- 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 caddy
|
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
|
||||||
|
|
||||||
- name: Sync PHP app checkout
|
- name: Sync PHP app checkout
|
||||||
run: >
|
run: >
|
||||||
@@ -240,6 +241,7 @@ jobs:
|
|||||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||||
|
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace \
|
-w /workspace \
|
||||||
node:22-alpine \
|
node:22-alpine \
|
||||||
@@ -253,3 +255,52 @@ jobs:
|
|||||||
- name: Tear down local stack
|
- name: Tear down local stack
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||||
|
|
||||||
|
release-manager-gate:
|
||||||
|
name: Release Manager gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Record Release Manager API gate
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
|
response_file="$(mktemp)"
|
||||||
|
http_code="$(curl --show-error --silent \
|
||||||
|
--connect-timeout 10 \
|
||||||
|
--retry 5 \
|
||||||
|
--retry-all-errors \
|
||||||
|
--retry-delay 15 \
|
||||||
|
--retry-max-time 300 \
|
||||||
|
-o "$response_file" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}")"
|
||||||
|
response_body="$(cat "$response_file")"
|
||||||
|
rm -f "$response_file"
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||||
|
printf '%s\n' "$response_body"
|
||||||
|
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
|
||||||
|
env:
|
||||||
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||||
|
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||||
|
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||||
|
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|||||||
@@ -10,5 +10,7 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
.env
|
.env
|
||||||
/services/caddy/logs*
|
/services/caddy/logs*
|
||||||
|
.env.old
|
||||||
/.tmp/
|
/.tmp/
|
||||||
/.env.staging
|
/.env.staging
|
||||||
|
/services/nginx/app/storage/replication-bootstrap.json
|
||||||
@@ -51,6 +51,7 @@ COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
|||||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
rm -f /var/www/html/storage/replication-bootstrap.json /var/www/html/storage/replication-bootstrap-*.json; \
|
||||||
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||||
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||||
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
|
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ $CONFIG_DB = [
|
|||||||
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
|
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
|
||||||
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
|
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
|
||||||
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
|
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
|
||||||
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
|
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
|
||||||
$ECONOMIC_API = [
|
$ECONOMIC_API = [
|
||||||
'app_access_grant' => '', // Economic API access grant token (1)
|
'app_access_grant' => '', // Economic API access grant token (1)
|
||||||
'app_access_grant2' => '', // Economic API access grant token (2)
|
'app_access_grant2' => '', // Economic API access grant token (2)
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ services:
|
|||||||
dockerfile: services/edge-broker/Dockerfile
|
dockerfile: services/edge-broker/Dockerfile
|
||||||
container_name: edge-broker
|
container_name: edge-broker
|
||||||
environment:
|
environment:
|
||||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
|
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
|
||||||
@@ -71,6 +71,7 @@ services:
|
|||||||
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
|
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
|
||||||
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
|
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
|
||||||
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.7.6-alpine
|
image: caddy:2.7.6-alpine
|
||||||
container_name: caddy
|
container_name: caddy
|
||||||
@@ -113,7 +114,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "true"
|
AUTO_COMPOSER_INSTALL: "true"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -134,7 +135,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
|
|||||||
+11
-10
@@ -101,8 +101,9 @@ services:
|
|||||||
mysql-debug:
|
mysql-debug:
|
||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
container_name: mysql-debug
|
container_name: mysql-debug
|
||||||
|
profiles: [dev]
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
||||||
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
@@ -121,9 +122,9 @@ services:
|
|||||||
dockerfile: services/edge-broker/Dockerfile
|
dockerfile: services/edge-broker/Dockerfile
|
||||||
container_name: edge-broker
|
container_name: edge-broker
|
||||||
environment:
|
environment:
|
||||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
|
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
|
||||||
@@ -307,7 +308,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "true"
|
AUTO_COMPOSER_INSTALL: "true"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -327,7 +328,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -347,7 +348,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -367,7 +368,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -387,7 +388,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -407,7 +408,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/staging:/var/www/html
|
- ./services/nginx/staging:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
@@ -427,7 +428,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTO_COMPOSER_INSTALL: "false"
|
AUTO_COMPOSER_INSTALL: "false"
|
||||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/nginx/app:/var/www/html
|
- ./services/nginx/app:/var/www/html
|
||||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||||
|
|||||||
+235
-5
@@ -78,6 +78,8 @@ tags:
|
|||||||
description: License plate scanning operations
|
description: License plate scanning operations
|
||||||
- name: Config
|
- name: Config
|
||||||
description: Module configuration management
|
description: Module configuration management
|
||||||
|
- name: Release Manager
|
||||||
|
description: Release channel, deployment, and operation management
|
||||||
- name: Branding
|
- name: Branding
|
||||||
description: Branding options management
|
description: Branding options management
|
||||||
- name: Roles
|
- name: Roles
|
||||||
@@ -4030,13 +4032,15 @@ paths:
|
|||||||
- name: buttons
|
- name: buttons
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
|
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
|
||||||
schema:
|
schema:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
- name: current_step
|
- name: current_step
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@@ -5844,7 +5848,7 @@ paths:
|
|||||||
reference: {type: string}
|
reference: {type: string}
|
||||||
po: {type: string}
|
po: {type: string}
|
||||||
pickup: {type: boolean}
|
pickup: {type: boolean}
|
||||||
order_id: {type: integer}
|
order_id: {type: integer, nullable: true}
|
||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -5870,6 +5874,32 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
|
|
||||||
|
/order-bookings/booking-confirmation/resend:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Bookings
|
||||||
|
summary: Resend order booking confirmation
|
||||||
|
description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department.
|
||||||
|
operationId: resendOrderBookingConfirmation
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id: {type: integer}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Booking confirmation resent successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
/order-bookings/complete:
|
/order-bookings/complete:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -7701,7 +7731,15 @@ paths:
|
|||||||
description: Worker status retrieved successfully
|
description: Worker status retrieved successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
api_commit_sha:
|
||||||
|
type: string
|
||||||
|
description: Running API commit SHA, or unknown when unavailable.
|
||||||
|
|
||||||
/worker/debug:
|
/worker/debug:
|
||||||
get:
|
get:
|
||||||
@@ -8953,6 +8991,10 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||||
|
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||||
|
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||||
|
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
|
||||||
|
department.
|
||||||
operationId: sendSelfServeLaneCommand
|
operationId: sendSelfServeLaneCommand
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -9003,6 +9045,9 @@ paths:
|
|||||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||||
|
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||||
|
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||||
|
confirming a wash start.
|
||||||
operationId: setSelfServeLaneAllowedServices
|
operationId: setSelfServeLaneAllowedServices
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -9446,7 +9491,9 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||||
enabled; an explicit call to this endpoint is required.
|
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||||
|
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||||
|
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||||
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
|
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
|
||||||
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
|
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
|
||||||
operationId: enableSelfServeLaneMachineRelay
|
operationId: enableSelfServeLaneMachineRelay
|
||||||
@@ -11982,6 +12029,189 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/superuser/releases/operations:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: List release operation runs
|
||||||
|
operationId: listReleaseOperations
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: channel_id
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: operation_type
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Release operation runs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/superuser/releases/operations/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Get release operation details
|
||||||
|
operationId: getReleaseOperation
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Release operation details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/superuser/releases/test-runs:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Run Release Manager diagnostics
|
||||||
|
operationId: runReleaseTest
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Release test operation started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
|
||||||
|
/superuser/releases/channels/{id}/sync:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Sync latest branch commits into a release channel
|
||||||
|
operationId: syncReleaseChannel
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Channel sync operation started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
|
||||||
|
/superuser/releases/issues/actions:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Run a Release Manager issue action
|
||||||
|
operationId: runReleaseIssueAction
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
issue_key:
|
||||||
|
type: string
|
||||||
|
action_id:
|
||||||
|
type: string
|
||||||
|
inputs:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
confirm:
|
||||||
|
type: boolean
|
||||||
|
additionalProperties: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Issue action result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
suite="${1:-}"
|
||||||
|
case "$suite" in
|
||||||
|
unit|integration|api|legacy|all)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||||||
|
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}}"
|
||||||
|
|
||||||
|
log_dir=".tmp/ci-logs/$suite"
|
||||||
|
mkdir -p "$log_dir"
|
||||||
|
|
||||||
|
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
|
||||||
|
mkdir -p "$env_backup_dir"
|
||||||
|
had_env=0
|
||||||
|
had_env_staging=0
|
||||||
|
if [ -f .env ]; then
|
||||||
|
cp .env "$env_backup_dir/env"
|
||||||
|
had_env=1
|
||||||
|
fi
|
||||||
|
if [ -f .env.staging ]; then
|
||||||
|
cp .env.staging "$env_backup_dir/env.staging"
|
||||||
|
had_env_staging=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp .github/ci.env .env
|
||||||
|
cp .github/ci.env.staging .env.staging
|
||||||
|
|
||||||
|
collect_logs() {
|
||||||
|
status="$1"
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$log_dir"
|
||||||
|
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
|
||||||
|
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
|
||||||
|
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
|
||||||
|
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status="$?"
|
||||||
|
collect_logs "$status"
|
||||||
|
docker compose $compose_files down -v >/dev/null 2>&1 || true
|
||||||
|
if [ "$had_env" -eq 1 ]; then
|
||||||
|
cp "$env_backup_dir/env" .env
|
||||||
|
else
|
||||||
|
rm -f .env
|
||||||
|
fi
|
||||||
|
if [ "$had_env_staging" -eq 1 ]; then
|
||||||
|
cp "$env_backup_dir/env.staging" .env.staging
|
||||||
|
else
|
||||||
|
rm -f .env.staging
|
||||||
|
fi
|
||||||
|
rm -rf "$env_backup_dir"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
docker compose $compose_files up -d redis mysql-debug php1
|
||||||
|
|
||||||
|
docker compose $compose_files exec -T php1 sh -lc '
|
||||||
|
set -eu
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
|
||||||
|
-h "${CONFIG_DB_HOST:-mysql-debug}" \
|
||||||
|
-P "${CONFIG_DB_PORT:-3306}" \
|
||||||
|
-u "${CONFIG_DB_USER:-root}" \
|
||||||
|
ping --silent >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Timed out waiting for mysql-debug" >&2
|
||||||
|
exit 1
|
||||||
|
'
|
||||||
|
|
||||||
|
tar \
|
||||||
|
--exclude='./vendor' \
|
||||||
|
--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 sh -lc \
|
||||||
|
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||||
|
|
||||||
|
docker compose $compose_files exec -T php1 sh -lc \
|
||||||
|
"cd /var/www/html && composer test:ci:$suite"
|
||||||
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
|
|||||||
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
|
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
|
||||||
|
|
||||||
const execFile = promisify(execFileCallback);
|
const execFile = promisify(execFileCallback);
|
||||||
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
|
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"];
|
||||||
|
|
||||||
function composeArgs(projectName, args) {
|
function composeArgs(projectName, args) {
|
||||||
return ["compose", "-p", projectName, ...args];
|
return ["compose", "-p", projectName, ...args];
|
||||||
@@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() {
|
|||||||
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
|
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldSkipComposeUp() {
|
||||||
|
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
function collectMessages(rows) {
|
function collectMessages(rows) {
|
||||||
return Array.isArray(rows)
|
return Array.isArray(rows)
|
||||||
? rows
|
? rows
|
||||||
@@ -570,7 +574,9 @@ async function main() {
|
|||||||
let runnerNetworkAttached = false;
|
let runnerNetworkAttached = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureComposeServices(rootDir, composeProject);
|
if (!shouldSkipComposeUp()) {
|
||||||
|
await ensureComposeServices(rootDir, composeProject);
|
||||||
|
}
|
||||||
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
|
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
|
||||||
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
|
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
|
||||||
process.stdout.write(`Using API base URL ${baseUrl}\n`);
|
process.stdout.write(`Using API base URL ${baseUrl}\n`);
|
||||||
|
|||||||
+18
-1
@@ -51,6 +51,23 @@ collect_logs() {
|
|||||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retry_command() {
|
||||||
|
max_attempts="$1"
|
||||||
|
shift
|
||||||
|
attempt=1
|
||||||
|
while :; do
|
||||||
|
"$@" && return 0
|
||||||
|
status="$?"
|
||||||
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||||
|
return "$status"
|
||||||
|
fi
|
||||||
|
sleep_seconds=$((attempt * 5))
|
||||||
|
echo "Command failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts): $*" >&2
|
||||||
|
sleep "$sleep_seconds"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
status="$?"
|
status="$?"
|
||||||
collect_logs "$status"
|
collect_logs "$status"
|
||||||
@@ -70,7 +87,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
docker compose $compose_files up -d redis mysql-debug php1
|
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc '
|
docker compose $compose_files exec -T php1 sh -lc '
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ function composeArgs(projectName, args) {
|
|||||||
return ["compose", "-p", projectName, ...args];
|
return ["compose", "-p", projectName, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usesWindowsPathSyntax(filePath) {
|
||||||
|
return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathForInputs(...filePaths) {
|
||||||
|
const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || "")));
|
||||||
|
|
||||||
|
return hasWindowsPath ? path.win32 : path;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRootDir(scriptPath) {
|
async function resolveRootDir(scriptPath) {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) {
|
|||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.basename(rootDir);
|
return pathForInputs(rootDir).basename(rootDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveComposeNetworkName(rootDir, env = process.env) {
|
export function resolveComposeNetworkName(rootDir, env = process.env) {
|
||||||
@@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfigDirectory(rootDir, explicitDir = null) {
|
export function resolveConfigDirectory(rootDir, explicitDir = null) {
|
||||||
|
const pathModule = pathForInputs(rootDir, explicitDir);
|
||||||
|
|
||||||
if (explicitDir) {
|
if (explicitDir) {
|
||||||
return path.resolve(rootDir, explicitDir);
|
return pathModule.resolve(rootDir, explicitDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(rootDir, ".tmp", "test-gateway");
|
return pathModule.join(rootDir, ".tmp", "test-gateway");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
|
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
# CORS is handled at the edge by Traefik's headers middleware.
|
# CORS is handled at the edge by Traefik's headers middleware.
|
||||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||||
|
|
||||||
|
# Do not expose local replication bootstrap material from the public web root.
|
||||||
|
# Bootstrap snapshots contain sensitive failover credentials.
|
||||||
|
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||||
|
respond @replicationBootstrap 404
|
||||||
|
|
||||||
# PHP handling via FastCGI to php-fpm pool
|
# PHP handling via FastCGI to php-fpm pool
|
||||||
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
|
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
# CORS is handled at the edge by Traefik's headers middleware.
|
# CORS is handled at the edge by Traefik's headers middleware.
|
||||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||||
|
|
||||||
|
# Do not expose local replication bootstrap material from the public web root.
|
||||||
|
# Bootstrap snapshots contain sensitive failover credentials.
|
||||||
|
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||||
|
respond @replicationBootstrap 404
|
||||||
|
|
||||||
# PHP handling via FastCGI to php-fpm pool
|
# PHP handling via FastCGI to php-fpm pool
|
||||||
php_fastcgi php-staging:9000
|
php_fastcgi php-staging:9000
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Vendored
+32
-2
@@ -18,6 +18,7 @@ const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
|
|||||||
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
||||||
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
||||||
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
|
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
|
||||||
|
const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||||
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
||||||
const execFile = promisify(execFileCallback);
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
@@ -151,6 +152,10 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isShellAccessEnabled(config = {}) {
|
||||||
|
return config.enableShellAccess === true;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBrokerBaseUrl(value) {
|
function normalizeBrokerBaseUrl(value) {
|
||||||
const trimmed = String(value || "").trim().replace(/\/+$/, "");
|
const trimmed = String(value || "").trim().replace(/\/+$/, "");
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
@@ -478,7 +483,7 @@ function resolveRelayToggleAfterSeconds(payload = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.floor(configured);
|
return Math.min(Math.floor(configured), MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, fetchImpl = fetch, options = {}) {
|
async function fetchJson(url, fetchImpl = fetch, options = {}) {
|
||||||
@@ -754,6 +759,15 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!expectedSha256) {
|
||||||
|
throw new Error(`${label} checksum is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedExpectedSha256 = String(expectedSha256).toLowerCase();
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(normalizedExpectedSha256)) {
|
||||||
|
throw new Error(`${label} checksum must be a valid sha256 hex digest`);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetchImpl(url);
|
const response = await fetchImpl(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${label} download failed: HTTP ${response.status}`);
|
throw new Error(`${label} download failed: HTTP ${response.status}`);
|
||||||
@@ -761,7 +775,7 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
|
|||||||
|
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||||
if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) {
|
if (normalizedExpectedSha256 !== sha256.toLowerCase()) {
|
||||||
throw new Error(`${label} checksum mismatch`);
|
throw new Error(`${label} checksum mismatch`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1785,6 +1799,10 @@ export async function processPolledShellAction(config, action, shell, fetchImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!isShellAccessEnabled(config)) {
|
||||||
|
throw new Error("Shell access is disabled by local configuration");
|
||||||
|
}
|
||||||
|
|
||||||
if (actionType === "OPEN") {
|
if (actionType === "OPEN") {
|
||||||
await shell.open(payload);
|
await shell.open(payload);
|
||||||
} else if (actionType === "INPUT") {
|
} else if (actionType === "INPUT") {
|
||||||
@@ -1919,18 +1937,30 @@ function createBrokerBridge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "OPEN_ROOT_SHELL") {
|
if (message.type === "OPEN_ROOT_SHELL") {
|
||||||
|
if (!isShellAccessEnabled(config)) {
|
||||||
|
throw new Error("Shell access is disabled by local configuration");
|
||||||
|
}
|
||||||
await shell.open(message.payload || {});
|
await shell.open(message.payload || {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "SHELL_INPUT") {
|
if (message.type === "SHELL_INPUT") {
|
||||||
|
if (!isShellAccessEnabled(config)) {
|
||||||
|
throw new Error("Shell access is disabled by local configuration");
|
||||||
|
}
|
||||||
shell.input(message.payload || {});
|
shell.input(message.payload || {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "RESIZE_ROOT_SHELL") {
|
if (message.type === "RESIZE_ROOT_SHELL") {
|
||||||
|
if (!isShellAccessEnabled(config)) {
|
||||||
|
throw new Error("Shell access is disabled by local configuration");
|
||||||
|
}
|
||||||
shell.resize(message.payload || {});
|
shell.resize(message.payload || {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "CLOSE_ROOT_SHELL") {
|
if (message.type === "CLOSE_ROOT_SHELL") {
|
||||||
|
if (!isShellAccessEnabled(config)) {
|
||||||
|
throw new Error("Shell access is disabled by local configuration");
|
||||||
|
}
|
||||||
shell.close(message.payload || {});
|
shell.close(message.payload || {});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFile as execFileCallback } from "node:child_process";
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
getRelayStatus,
|
getRelayStatus,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
parseCliArgs,
|
parseCliArgs,
|
||||||
|
processPolledShellAction,
|
||||||
processPolledCommand,
|
processPolledCommand,
|
||||||
runCli,
|
runCli,
|
||||||
runUpdate,
|
runUpdate,
|
||||||
@@ -39,6 +41,10 @@ function makeFetchResponse(body) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha256Hex(body) {
|
||||||
|
return createHash("sha256").update(body).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
|
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
@@ -270,6 +276,31 @@ test("relay switch commands pass timer values to local Shelly APIs", async () =>
|
|||||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
||||||
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const cappedUrls = [];
|
||||||
|
const cappedFetch = async (url) => {
|
||||||
|
cappedUrls.push(String(url));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { output: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await setRelayState({
|
||||||
|
localIp: "10.1.0.31",
|
||||||
|
channel: 0,
|
||||||
|
on: true,
|
||||||
|
toggle_after: 999999999,
|
||||||
|
device_generation: 3,
|
||||||
|
}, cappedFetch);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
cappedUrls[0],
|
||||||
|
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=5"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
||||||
@@ -294,19 +325,23 @@ test("runUpdate stages a pending verification restart after installing new artif
|
|||||||
execCalls.push({ command, args, options });
|
execCalls.push({ command, args, options });
|
||||||
return { stdout: "{}" };
|
return { stdout: "{}" };
|
||||||
};
|
};
|
||||||
|
const agentBody = "// new agent\n";
|
||||||
|
const packageBody = JSON.stringify({ name: "new-edge-agent" }, null, 2);
|
||||||
const fakeFetch = async (url) => {
|
const fakeFetch = async (url) => {
|
||||||
if (String(url).endsWith("/agent.mjs")) {
|
if (String(url).endsWith("/agent.mjs")) {
|
||||||
return makeFetchResponse("// new agent\n");
|
return makeFetchResponse(agentBody);
|
||||||
}
|
}
|
||||||
if (String(url).endsWith("/package.json")) {
|
if (String(url).endsWith("/package.json")) {
|
||||||
return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2));
|
return makeFetchResponse(packageBody);
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected URL: ${url}`);
|
throw new Error(`Unexpected URL: ${url}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runUpdate({
|
const result = await runUpdate({
|
||||||
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
||||||
|
sha256: sha256Hex(agentBody),
|
||||||
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
|
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
|
||||||
|
packageSha256: sha256Hex(packageBody),
|
||||||
targetVersion: "1.1.0",
|
targetVersion: "1.1.0",
|
||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
restartMode: "spawn",
|
restartMode: "spawn",
|
||||||
@@ -334,6 +369,45 @@ test("runUpdate stages a pending verification restart after installing new artif
|
|||||||
|
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
test("runUpdate rejects artifacts without required checksums", async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-checksum-"));
|
||||||
|
const configPath = path.join(tempDir, "config.json");
|
||||||
|
const liveConfig = {
|
||||||
|
apiUrl: "https://api.example.test",
|
||||||
|
gatewayId: 42,
|
||||||
|
agentToken: "agent-token",
|
||||||
|
installDir: tempDir,
|
||||||
|
restartMode: "spawn",
|
||||||
|
installedVersion: "1.0.0",
|
||||||
|
targetVersion: "1.0.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(configPath, JSON.stringify(liveConfig, null, 2));
|
||||||
|
await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n");
|
||||||
|
|
||||||
|
let fetchCalled = false;
|
||||||
|
await assert.rejects(
|
||||||
|
runUpdate({
|
||||||
|
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
||||||
|
targetVersion: "1.1.0",
|
||||||
|
}, async () => {
|
||||||
|
fetchCalled = true;
|
||||||
|
return makeFetchResponse("// new agent\n");
|
||||||
|
}, {
|
||||||
|
configPath,
|
||||||
|
config: liveConfig,
|
||||||
|
liveConfig,
|
||||||
|
execFileImpl: async () => ({ stdout: "{}" }),
|
||||||
|
}),
|
||||||
|
/Agent artifact checksum is required/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n");
|
||||||
|
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
|
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
|
||||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
|
||||||
@@ -670,6 +744,7 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
|||||||
commandPollRetryDelayMs: 5,
|
commandPollRetryDelayMs: 5,
|
||||||
shellActionPollTimeoutSeconds: 0,
|
shellActionPollTimeoutSeconds: 0,
|
||||||
shellActionPollRetryDelayMs: 5,
|
shellActionPollRetryDelayMs: 5,
|
||||||
|
enableShellAccess: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const heartbeats = [];
|
const heartbeats = [];
|
||||||
@@ -928,6 +1003,61 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("processPolledShellAction denies shell access when locally disabled", async () => {
|
||||||
|
const submissions = [];
|
||||||
|
const fakeFetch = async (url, options = {}) => {
|
||||||
|
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
|
||||||
|
submissions.push({ url, body: JSON.parse(options.body) });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { data: { acknowledged: true } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected URL: ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shell = {
|
||||||
|
async open() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
input() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
resize() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await processPolledShellAction(
|
||||||
|
{
|
||||||
|
apiUrl: "https://api.example.test",
|
||||||
|
gatewayId: 42,
|
||||||
|
agentToken: "agent-token",
|
||||||
|
enableShellAccess: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 501,
|
||||||
|
actionType: "OPEN",
|
||||||
|
payload: {
|
||||||
|
sessionId: 44,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shell,
|
||||||
|
fakeFetch
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /Shell access is disabled/);
|
||||||
|
assert.equal(submissions.length, 1);
|
||||||
|
assert.equal(submissions[0].body.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
test("status helpers report config without exposing the agent token", async () => {
|
test("status helpers report config without exposing the agent token", async () => {
|
||||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
|
||||||
const configPath = path.join(tempDir, "config.json");
|
const configPath = path.join(tempDir, "config.json");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
|
||||||
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
||||||
|
const TELEMETRY_INGEST_ERROR_MESSAGE = "Telemetry ingestion failed";
|
||||||
|
|
||||||
function parseJsonBody(req) {
|
function parseJsonBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -55,7 +56,33 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
|||||||
if (process.env.EDGE_AUTH_MODE) {
|
if (process.env.EDGE_AUTH_MODE) {
|
||||||
return process.env.EDGE_AUTH_MODE;
|
return process.env.EDGE_AUTH_MODE;
|
||||||
}
|
}
|
||||||
return "manager";
|
return "strict";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSharedSecret(options = {}) {
|
||||||
|
return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSharedSecret(req, res, sharedSecret) {
|
||||||
|
if (sharedSecret === "") {
|
||||||
|
jsonResponse(res, 503, {
|
||||||
|
ok: false,
|
||||||
|
error: "Edge broker shared secret is not configured",
|
||||||
|
shared_secret_required: true,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||||
|
jsonResponse(res, 403, {
|
||||||
|
ok: false,
|
||||||
|
error: "Forbidden",
|
||||||
|
shared_secret_required: true,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseScopes(value) {
|
function parseScopes(value) {
|
||||||
@@ -150,7 +177,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBrokerServer(options = {}) {
|
export function createBrokerServer(options = {}) {
|
||||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
const sharedSecret = resolveSharedSecret(options);
|
||||||
const managerUrl = resolveManagerUrl(options);
|
const managerUrl = resolveManagerUrl(options);
|
||||||
const authMode = resolveAuthMode(options, managerUrl);
|
const authMode = resolveAuthMode(options, managerUrl);
|
||||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||||
@@ -460,25 +487,19 @@ export function createBrokerServer(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
||||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||||
jsonResponse(res, 403, {
|
|
||||||
ok: false,
|
|
||||||
error: "Forbidden",
|
|
||||||
shared_secret_required: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
shared_secret_required: Boolean(sharedSecret),
|
shared_secret_required: true,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
||||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||||
jsonResponse(res, 403, { error: "Forbidden" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,8 +542,7 @@ export function createBrokerServer(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||||
jsonResponse(res, 403, { error: "Forbidden" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,12 +576,13 @@ export function createBrokerServer(options = {}) {
|
|||||||
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503;
|
const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503;
|
||||||
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), {
|
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", "Gateway agent could not be validated.", {
|
||||||
stage: "agent_validate",
|
stage: "agent_validate",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
const existing = agents.get(gatewayId);
|
const existing = agents.get(gatewayId);
|
||||||
if (existing && existing.readyState < 2) {
|
if (existing && existing.readyState < 2) {
|
||||||
@@ -622,12 +643,13 @@ export function createBrokerServer(options = {}) {
|
|||||||
try {
|
try {
|
||||||
session = await validateShellSession({ token, headers: req.headers });
|
session = await validateShellSession({ token, headers: req.headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), {
|
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", "Shell session could not be validated.", {
|
||||||
stage: "shell_session_validate",
|
stage: "shell_session_validate",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
ws.sessionToken = token;
|
ws.sessionToken = token;
|
||||||
ws.sessionInfo = session;
|
ws.sessionInfo = session;
|
||||||
@@ -722,7 +744,7 @@ export function createBrokerServer(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
|
rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,8 +787,8 @@ export function createBrokerServer(options = {}) {
|
|||||||
let ingestError = null;
|
let ingestError = null;
|
||||||
try {
|
try {
|
||||||
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||||
} catch (error) {
|
} catch {
|
||||||
ingestError = error instanceof Error ? error.message : String(error);
|
ingestError = TELEMETRY_INGEST_ERROR_MESSAGE;
|
||||||
}
|
}
|
||||||
const fallbackStatistics = {
|
const fallbackStatistics = {
|
||||||
system_metrics: payload?.metadata?.system_metrics || {},
|
system_metrics: payload?.metadata?.system_metrics || {},
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ function waitForClose(socket) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForCloseOrError(socket) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onDone = () => {
|
||||||
|
socket.off("error", onDone);
|
||||||
|
socket.off("close", onDone);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
socket.once("error", onDone);
|
||||||
|
socket.once("close", onDone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function rawUpgradeRequest(port, path) {
|
function rawUpgradeRequest(port, path) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
||||||
@@ -59,7 +71,7 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti
|
|||||||
throw new Error(`Timed out waiting for ${description}`);
|
throw new Error(`Timed out waiting for ${description}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("broker defaults to manager auth and fails closed when manager URL is missing", async () => {
|
test("broker defaults to strict auth and fails closed when manager URL is missing", async () => {
|
||||||
const previousEnv = {
|
const previousEnv = {
|
||||||
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
||||||
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
|
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
|
||||||
@@ -72,7 +84,7 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
|||||||
let broker;
|
let broker;
|
||||||
try {
|
try {
|
||||||
broker = createBrokerServer({ sharedSecret: "secret" });
|
broker = createBrokerServer({ sharedSecret: "secret" });
|
||||||
assert.equal(broker.state.authMode, "manager");
|
assert.equal(broker.state.authMode, "strict");
|
||||||
assert.equal(broker.state.managerUrl, "");
|
assert.equal(broker.state.managerUrl, "");
|
||||||
|
|
||||||
const address = await broker.listen(0);
|
const address = await broker.listen(0);
|
||||||
@@ -83,13 +95,15 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
|||||||
assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
|
assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
|
||||||
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||||
assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
|
assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
|
||||||
assert.match(shellResponse, /Edge manager URL is not configured/);
|
assert.match(shellResponse, /"message":"Shell session could not be validated\."/);
|
||||||
|
assert.doesNotMatch(shellResponse, /Edge manager URL is not configured/);
|
||||||
|
|
||||||
assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
|
assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
|
||||||
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
||||||
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
||||||
assert.match(agentResponse, /"stage":"agent_validate"/);
|
assert.match(agentResponse, /"stage":"agent_validate"/);
|
||||||
assert.match(agentResponse, /Edge manager URL is not configured/);
|
assert.match(agentResponse, /"message":"Gateway agent could not be validated\."/);
|
||||||
|
assert.doesNotMatch(agentResponse, /Edge manager URL is not configured/);
|
||||||
} finally {
|
} finally {
|
||||||
if (broker) {
|
if (broker) {
|
||||||
await broker.close();
|
await broker.close();
|
||||||
@@ -104,6 +118,53 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("broker rejects protected HTTP endpoints when shared secret is missing", async () => {
|
||||||
|
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "", commandTimeoutMs: 2000 });
|
||||||
|
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));
|
||||||
|
const agentMessages = collectMessages(agent);
|
||||||
|
|
||||||
|
const commandResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
commandType: "SET_RELAY_STATE",
|
||||||
|
payload: { relayId: "M-7", on: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const commandJson = await commandResponse.json();
|
||||||
|
|
||||||
|
assert.equal(commandResponse.status, 503);
|
||||||
|
assert.equal(commandJson.ok, false);
|
||||||
|
assert.equal(commandJson.shared_secret_required, true);
|
||||||
|
assert.match(commandJson.error, /shared secret is not configured/);
|
||||||
|
assert.equal(agentMessages.some((message) => message.type === "COMMAND"), false);
|
||||||
|
|
||||||
|
const diagnosticsResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const diagnosticsJson = await diagnosticsResponse.json();
|
||||||
|
|
||||||
|
assert.equal(diagnosticsResponse.status, 503);
|
||||||
|
assert.equal(diagnosticsJson.shared_secret_required, true);
|
||||||
|
|
||||||
|
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const syncJson = await syncResponse.json();
|
||||||
|
|
||||||
|
assert.equal(syncResponse.status, 503);
|
||||||
|
assert.equal(syncJson.shared_secret_required, true);
|
||||||
|
|
||||||
|
agent.terminate();
|
||||||
|
await broker.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("broker dispatches commands to connected agents", async () => {
|
test("broker dispatches commands to connected agents", async () => {
|
||||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
||||||
const address = await broker.listen(0);
|
const address = await broker.listen(0);
|
||||||
@@ -338,11 +399,34 @@ test("broker rejects invalid browser shell upgrades without leaking the token",
|
|||||||
|
|
||||||
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
|
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||||
assert.match(response, /"error_code":"shell_session_expired"/);
|
assert.match(response, /"error_code":"shell_session_expired"/);
|
||||||
|
assert.match(response, /"message":"Shell session could not be validated\."/);
|
||||||
|
assert.doesNotMatch(response, /Shell session expired/);
|
||||||
assert.doesNotMatch(response, new RegExp(rawToken));
|
assert.doesNotMatch(response, new RegExp(rawToken));
|
||||||
|
|
||||||
await broker.close();
|
await broker.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("broker rejects websocket upgrade errors without exposing exception text", async () => {
|
||||||
|
const broker = createBrokerServer({
|
||||||
|
authMode: "stub",
|
||||||
|
validateBrowserStream: async () => {
|
||||||
|
throw new Error("UPSTREAM-SENSITIVE: redis://cache.internal:6379 timeout");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const address = await broker.listen(0);
|
||||||
|
const port = address.port;
|
||||||
|
|
||||||
|
const response = await rawUpgradeRequest(port, "/ws/browser-gateway-stream?token=session-token");
|
||||||
|
|
||||||
|
assert.match(response, /^HTTP\/1\.1 500 Internal Server Error/m);
|
||||||
|
assert.match(response, /"error_code":"websocket_upgrade_failed"/);
|
||||||
|
assert.match(response, /"message":"WebSocket upgrade failed\."/);
|
||||||
|
assert.doesNotMatch(response, /UPSTREAM-SENSITIVE/);
|
||||||
|
assert.doesNotMatch(response, /redis:\/\/cache\.internal/);
|
||||||
|
|
||||||
|
await broker.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
|
test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
|
||||||
const closedSessions = [];
|
const closedSessions = [];
|
||||||
const broker = createBrokerServer({
|
const broker = createBrokerServer({
|
||||||
@@ -418,6 +502,17 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
|||||||
await broker.close();
|
await broker.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("broker defaults to strict auth when no validators are configured", async () => {
|
||||||
|
const broker = createBrokerServer();
|
||||||
|
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 waitForCloseOrError(agent);
|
||||||
|
|
||||||
|
await broker.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
|
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
|
||||||
const broker = createBrokerServer({
|
const broker = createBrokerServer({
|
||||||
authMode: "stub",
|
authMode: "stub",
|
||||||
@@ -743,9 +838,16 @@ test("broker still fans out telemetry when manager ingestion fails", async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"),
|
() =>
|
||||||
|
browserMessages.some(
|
||||||
|
(message) => message.type === "gateway.telemetry" && message.error === "Telemetry ingestion failed"
|
||||||
|
),
|
||||||
{ description: "telemetry fanout after ingest failure" }
|
{ description: "telemetry fanout after ingest failure" }
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
browserMessages.every((message) => message.error !== "manager unavailable"),
|
||||||
|
"raw manager errors must not be sent to browser streams"
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
browserMessages.some(
|
browserMessages.some(
|
||||||
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test("traefik does not expose a dedicated public edge broker port", () => {
|
|||||||
test("base docker compose routes edge broker traffic through traefik", () => {
|
test("base docker compose routes edge broker traffic through traefik", () => {
|
||||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
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_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||||
@@ -55,7 +55,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
|||||||
test("example docker compose routes edge broker traffic through traefik", () => {
|
test("example docker compose routes edge broker traffic through traefik", () => {
|
||||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
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_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
|
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-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\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||||
@@ -76,10 +76,10 @@ test("standalone production compose routes edge broker traffic through traefik",
|
|||||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("php services receive broker websocket environment defaults", () => {
|
test("compose config does not provide insecure broker secret defaults", () => {
|
||||||
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
|
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_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +88,6 @@ test("base docker compose wires the broker into each php worker", () => {
|
|||||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
||||||
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
|
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_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
|
|||||||
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
||||||
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
||||||
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
||||||
|
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
|
||||||
|
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
|
||||||
|
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
|
||||||
|
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
|
||||||
|
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
|
||||||
|
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
|
||||||
|
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
|
||||||
|
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
|
||||||
|
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
|
||||||
|
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
|
||||||
|
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
|
||||||
|
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
|
||||||
|
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
|
||||||
|
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
|
||||||
|
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
|
||||||
|
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
|
||||||
|
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
|
||||||
|
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
|
||||||
|
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
|
||||||
|
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
|
||||||
|
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
|
||||||
|
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
|
||||||
|
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
|
||||||
|
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
|
||||||
|
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
|
||||||
|
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
|
||||||
|
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
|
||||||
|
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
|
||||||
|
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
|
||||||
|
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
|
||||||
|
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
|
||||||
|
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
|
||||||
|
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
|
||||||
|
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
|
||||||
|
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
|
||||||
|
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
|
||||||
|
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
|
||||||
|
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
|
||||||
|
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
|
||||||
|
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
|
||||||
|
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
|
||||||
|
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
|
||||||
|
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
|
||||||
|
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
|
||||||
|
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
|
||||||
|
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
|
||||||
|
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
|
||||||
|
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
|
||||||
|
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
|
||||||
|
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
|
||||||
|
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
|
||||||
|
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
|
||||||
|
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
|
||||||
|
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
|
||||||
|
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
|
||||||
|
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
|
||||||
|
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
|
||||||
|
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
|
||||||
|
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
|
||||||
|
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
|
||||||
|
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
|
||||||
|
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
|
||||||
|
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
|
||||||
|
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
|
||||||
|
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
|
||||||
|
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
|
||||||
|
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
|
||||||
|
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
|
||||||
|
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
|
||||||
|
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
|
||||||
|
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
|
||||||
|
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
|
||||||
|
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
|
||||||
|
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
|
||||||
|
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
|
||||||
|
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
|
||||||
|
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
|
||||||
|
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
|
||||||
|
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
|
||||||
|
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
|
||||||
|
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
|
||||||
|
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
|
||||||
|
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
|
||||||
|
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use classes\totp;
|
|||||||
use Exception;
|
use Exception;
|
||||||
use interfaces\authentication_i;
|
use interfaces\authentication_i;
|
||||||
use objects\plate_scanners_o;
|
use objects\plate_scanners_o;
|
||||||
|
use objects\subuser_grants_o;
|
||||||
use objects\tokens_o;
|
use objects\tokens_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
use objects\subusers_o;
|
use objects\subusers_o;
|
||||||
@@ -125,9 +126,13 @@ class authentication implements authentication_i
|
|||||||
public function validate_token(string $token): bool
|
public function validate_token(string $token): bool
|
||||||
{
|
{
|
||||||
// First: try validating as a classic user auth token
|
// First: try validating as a classic user auth token
|
||||||
$dbToken = (new tokens_o())->getToken($token);
|
try {
|
||||||
if ($dbToken && $dbToken->id) {
|
$dbToken = (new tokens_o())->getToken($token);
|
||||||
return true;
|
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception) {
|
||||||
|
// Ignore and continue to subuser session validation
|
||||||
}
|
}
|
||||||
// Fallback: try validating as a subuser session token
|
// Fallback: try validating as a subuser session token
|
||||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||||
@@ -154,19 +159,17 @@ class authentication implements authentication_i
|
|||||||
// Strip the Bearer prefix
|
// Strip the Bearer prefix
|
||||||
$rawToken = str_replace('Bearer ', '', $rawToken);
|
$rawToken = str_replace('Bearer ', '', $rawToken);
|
||||||
// Get the token from the database
|
// Get the token from the database
|
||||||
$token = (new tokens_o())->getToken($rawToken);
|
try {
|
||||||
|
$token = (new tokens_o())->getToken($rawToken);
|
||||||
|
} catch (Exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Check if the token exists
|
// Check if the token exists
|
||||||
if (!$token->id) {
|
if (!$token->id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($token->type->value() === "AUTH_TOKEN_SUBUSER") {
|
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||||
// Get the customer number from the headers
|
return false;
|
||||||
if (!isset($headers['X-Customer-Number'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$customer_number = (int)$headers['X-Customer-Number'];
|
|
||||||
// Get the user by the customer number
|
|
||||||
return (new users_o())->getUserByCustomerNumber($customer_number);
|
|
||||||
}
|
}
|
||||||
// Get the user from the database
|
// Get the user from the database
|
||||||
$user = (new users_o())->getUserById($token->user_id->value());
|
$user = (new users_o())->getUserById($token->user_id->value());
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ class coolify_api_client
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||||
|
{
|
||||||
|
if ($env === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||||
|
'data' => self::bulkEnvData($env),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||||
|
{
|
||||||
|
if ($env === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||||
|
'data' => self::bulkEnvData($env),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bulkEnvData(array $env): array
|
||||||
{
|
{
|
||||||
$data = [];
|
$data = [];
|
||||||
foreach ($env as $key => $value) {
|
foreach ($env as $key => $value) {
|
||||||
@@ -113,11 +135,7 @@ class coolify_api_client
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($data === []) {
|
return $data;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deployResource(string $uuid, bool $force = false): array
|
public function deployResource(string $uuid, bool $force = false): array
|
||||||
|
|||||||
@@ -1926,13 +1926,14 @@ class coolify_manager
|
|||||||
], $actorUserId);
|
], $actorUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$deployment = $releaseManager->startDeployment([
|
$sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? ''));
|
||||||
|
$deploymentInput = [
|
||||||
'target_id' => (int)($deploymentTarget['id'] ?? 0),
|
'target_id' => (int)($deploymentTarget['id'] ?? 0),
|
||||||
'channel_id' => (int)$sourceTarget['channel_id'],
|
'channel_id' => (int)$sourceTarget['channel_id'],
|
||||||
'app' => $app,
|
'app' => $app,
|
||||||
'repository' => (string)($sourceTarget['repository'] ?? ''),
|
'repository' => (string)($sourceTarget['repository'] ?? ''),
|
||||||
'branch' => (string)($sourceTarget['branch'] ?? 'master'),
|
'branch' => (string)($sourceTarget['branch'] ?? 'master'),
|
||||||
'commit_mode' => 'latest',
|
'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific',
|
||||||
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
|
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
|
||||||
'deployed_url' => $sourcePublicUrl,
|
'deployed_url' => $sourcePublicUrl,
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
@@ -1942,7 +1943,11 @@ class coolify_manager
|
|||||||
'server_uuid' => $serverUuid,
|
'server_uuid' => $serverUuid,
|
||||||
'app' => $app,
|
'app' => $app,
|
||||||
],
|
],
|
||||||
], $actorUserId);
|
];
|
||||||
|
if ($sourceCommitSha !== '') {
|
||||||
|
$deploymentInput['commit_sha'] = $sourceCommitSha;
|
||||||
|
}
|
||||||
|
$deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId);
|
||||||
|
|
||||||
if ((string)($deployment['status'] ?? '') !== 'deployed') {
|
if ((string)($deployment['status'] ?? '') !== 'deployed') {
|
||||||
$errors[] = array_replace($action, [
|
$errors[] = array_replace($action, [
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
class cors_policy
|
||||||
|
{
|
||||||
|
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||||
|
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||||
|
public const MAX_AGE_SECONDS = '86400';
|
||||||
|
|
||||||
|
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||||
|
'https://truckwash.io',
|
||||||
|
'https://www.truckwash.io',
|
||||||
|
'https://api.truckwash.io',
|
||||||
|
'https://api.truckwash.io:4433',
|
||||||
|
'https://api-v2.truckwash.io',
|
||||||
|
'https://web.truckwash.dk',
|
||||||
|
'https://api.truckwash.dk',
|
||||||
|
'https://truckwash.dk',
|
||||||
|
'https://www.truckwash.dk',
|
||||||
|
'https://staging.truckwash.io',
|
||||||
|
'http://localhost',
|
||||||
|
'https://localhost',
|
||||||
|
'http://localhost:4433',
|
||||||
|
'https://localhost:4433',
|
||||||
|
'https://twdev.jeppeb.dk',
|
||||||
|
'http://localhost:5173',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function normalizeOrigin(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
if ($value === '' || $value === '*') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($value);
|
||||||
|
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower((string)$parts['scheme']);
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower((string)$parts['host']);
|
||||||
|
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function requiredAllowedOrigins(): array
|
||||||
|
{
|
||||||
|
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function allowedOrigins(string $corsConfig): array
|
||||||
|
{
|
||||||
|
$origins = [];
|
||||||
|
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
||||||
|
if ($configuredOrigin === '*') {
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = self::normalizeOrigin($configuredOrigin);
|
||||||
|
if ($origin !== '') {
|
||||||
|
$origins[$origin] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
||||||
|
$origin = self::normalizeOrigin($requiredOrigin);
|
||||||
|
if ($origin !== '') {
|
||||||
|
$origins[$origin] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withRequiredOrigins(string $corsConfig): string
|
||||||
|
{
|
||||||
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||||
|
if ($allowedOrigins === ['*']) {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(',', $allowedOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||||
|
{
|
||||||
|
$origin = self::normalizeOrigin($origin);
|
||||||
|
if ($origin === '' || $origin === '*') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||||
|
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||||
|
{
|
||||||
|
$origin = self::normalizeOrigin($origin);
|
||||||
|
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Access-Control-Allow-Origin' => $origin,
|
||||||
|
'Access-Control-Allow-Credentials' => 'true',
|
||||||
|
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||||
|
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||||
|
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||||
|
'Vary' => 'Origin',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
||||||
|
*/
|
||||||
|
public static function preflightResponse(?string $origin, string $corsConfig): array
|
||||||
|
{
|
||||||
|
$headers = self::responseHeaders($origin, $corsConfig);
|
||||||
|
if ($headers === []) {
|
||||||
|
return [
|
||||||
|
'allowed' => false,
|
||||||
|
'status' => 403,
|
||||||
|
'headers' => ['Content-Type' => 'application/json'],
|
||||||
|
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers['Content-Type'] = 'application/json';
|
||||||
|
return [
|
||||||
|
'allowed' => true,
|
||||||
|
'status' => 200,
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
||||||
|
{
|
||||||
|
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
||||||
|
if ($headers === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::emitHeaders($headers);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $headers
|
||||||
|
*/
|
||||||
|
public static function emitHeaders(array $headers): void
|
||||||
|
{
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private static function splitOrigins(string $corsConfig): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
array_map('trim', explode(',', $corsConfig)),
|
||||||
|
static fn(string $origin): bool => $origin !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ class economic_transfer_queue
|
|||||||
$transfer_type = $this->validateTransferType($transfer_type);
|
$transfer_type = $this->validateTransferType($transfer_type);
|
||||||
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
||||||
|
|
||||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload);
|
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
|
||||||
if ($active_job !== null) {
|
if ($active_job !== null) {
|
||||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||||
$this->logQueueEvent(
|
$this->logQueueEvent(
|
||||||
@@ -135,6 +135,89 @@ class economic_transfer_queue
|
|||||||
return $jobs;
|
return $jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getJobByIdForUser(int $job_id, int $created_by): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$job_id = max(0, $job_id);
|
||||||
|
$created_by = max(0, $created_by);
|
||||||
|
if ($job_id < 1 || $created_by < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
|
||||||
|
if (!$stmt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param('ii', $job_id, $created_by);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
$stmt->close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->normalizeJobRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listJobsForCreatedBy(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null, int $created_by = 0): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$created_by = max(0, $created_by);
|
||||||
|
if ($created_by < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max(1, min(500, $limit));
|
||||||
|
$offset = max(0, $offset);
|
||||||
|
|
||||||
|
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||||
|
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||||
|
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result instanceof mysqli_result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$jobs[] = $this->normalizeJobRow($row);
|
||||||
|
}
|
||||||
|
return $jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countJobsForCreatedBy(array $statuses = [], ?string $transfer_type = null, int $created_by = 0): int
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$created_by = max(0, $created_by);
|
||||||
|
if ($created_by < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||||
|
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||||
|
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if (!$result instanceof mysqli_result) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
if (!is_array($row) || !isset($row['total'])) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int)$row['total']);
|
||||||
|
}
|
||||||
|
|
||||||
public function countJobs(array $statuses = [], ?string $transfer_type = null): int
|
public function countJobs(array $statuses = [], ?string $transfer_type = null): int
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
@@ -179,7 +262,7 @@ class economic_transfer_queue
|
|||||||
ON d.queue_job_id = q.id
|
ON d.queue_job_id = q.id
|
||||||
AND d.user_id = $user_id
|
AND d.user_id = $user_id
|
||||||
AND d.dismissed_status = q.status
|
AND d.dismissed_status = q.status
|
||||||
WHERE 1 = 1
|
WHERE q.created_by = $user_id
|
||||||
$transfer_condition
|
$transfer_condition
|
||||||
AND (
|
AND (
|
||||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||||
@@ -214,7 +297,7 @@ class economic_transfer_queue
|
|||||||
throw new Exception('Queue job and user are required');
|
throw new Exception('Queue job and user are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
$job = $this->getJobById($job_id);
|
$job = $this->getJobByIdForUser($job_id, $user_id);
|
||||||
if ($job === null) {
|
if ($job === null) {
|
||||||
throw new Exception('Queue job not found');
|
throw new Exception('Queue job not found');
|
||||||
}
|
}
|
||||||
@@ -272,7 +355,8 @@ class economic_transfer_queue
|
|||||||
ON d.queue_job_id = q.id
|
ON d.queue_job_id = q.id
|
||||||
AND d.user_id = $user_id
|
AND d.user_id = $user_id
|
||||||
AND d.dismissed_status = q.status
|
AND d.dismissed_status = q.status
|
||||||
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
WHERE q.created_by = $user_id
|
||||||
|
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||||
$transfer_condition
|
$transfer_condition
|
||||||
AND d.queue_job_id IS NULL
|
AND d.queue_job_id IS NULL
|
||||||
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
||||||
@@ -284,10 +368,24 @@ class economic_transfer_queue
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function retryJob(int $job_id): array
|
public function retryJob(int $job_id): array
|
||||||
|
{
|
||||||
|
return $this->retryJobInternal($job_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryJobForUser(int $job_id, int $created_by): array
|
||||||
|
{
|
||||||
|
return $this->retryJobInternal($job_id, $created_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function retryJobInternal(int $job_id, ?int $created_by = null): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
$existing_job = $this->getJobById($job_id);
|
$job_id = max(0, $job_id);
|
||||||
|
$created_by = $created_by === null ? null : max(0, $created_by);
|
||||||
|
$existing_job = $created_by === null
|
||||||
|
? $this->getJobById($job_id)
|
||||||
|
: $this->getJobByIdForUser($job_id, $created_by);
|
||||||
if ($existing_job === null) {
|
if ($existing_job === null) {
|
||||||
throw new Exception('Queue job not found');
|
throw new Exception('Queue job not found');
|
||||||
}
|
}
|
||||||
@@ -298,19 +396,26 @@ class economic_transfer_queue
|
|||||||
throw new Exception('Queue job reached max retry attempts');
|
throw new Exception('Queue job reached max retry attempts');
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare(
|
$sql = "UPDATE economic_transfer_queue_jobs
|
||||||
"UPDATE economic_transfer_queue_jobs
|
|
||||||
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
|
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
|
||||||
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
|
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
|
||||||
WHERE id = ? AND status = ?"
|
WHERE id = ? AND status = ?";
|
||||||
);
|
if ($created_by !== null) {
|
||||||
|
$sql .= " AND created_by = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
throw new Exception('Failed to prepare retry statement');
|
throw new Exception('Failed to prepare retry statement');
|
||||||
}
|
}
|
||||||
|
|
||||||
$queued = self::STATUS_QUEUED;
|
$queued = self::STATUS_QUEUED;
|
||||||
$failed = self::STATUS_FAILED;
|
$failed = self::STATUS_FAILED;
|
||||||
$stmt->bind_param('sis', $queued, $job_id, $failed);
|
if ($created_by !== null) {
|
||||||
|
$stmt->bind_param('sisi', $queued, $job_id, $failed, $created_by);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param('sis', $queued, $job_id, $failed);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$affected = $stmt->affected_rows;
|
$affected = $stmt->affected_rows;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -321,7 +426,9 @@ class economic_transfer_queue
|
|||||||
|
|
||||||
$this->clearDismissalsForJob($job_id);
|
$this->clearDismissalsForJob($job_id);
|
||||||
|
|
||||||
$job = $this->getJobById($job_id);
|
$job = $created_by === null
|
||||||
|
? $this->getJobById($job_id)
|
||||||
|
: $this->getJobByIdForUser($job_id, $created_by);
|
||||||
if ($job === null) {
|
if ($job === null) {
|
||||||
throw new Exception('Retry updated job could not be loaded');
|
throw new Exception('Retry updated job could not be loaded');
|
||||||
}
|
}
|
||||||
@@ -710,28 +817,31 @@ class economic_transfer_queue
|
|||||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findActiveJobByTarget(string $transfer_type, array $payload): ?array
|
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
|
||||||
{
|
{
|
||||||
return match ($transfer_type) {
|
return match ($transfer_type) {
|
||||||
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||||
$transfer_type,
|
$transfer_type,
|
||||||
'$.order_id',
|
'$.order_id',
|
||||||
(int)($payload['order_id'] ?? 0)
|
(int)($payload['order_id'] ?? 0),
|
||||||
|
$created_by
|
||||||
),
|
),
|
||||||
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||||
$transfer_type,
|
$transfer_type,
|
||||||
'$.collected_invoice_id',
|
'$.collected_invoice_id',
|
||||||
(int)($payload['collected_invoice_id'] ?? 0)
|
(int)($payload['collected_invoice_id'] ?? 0),
|
||||||
|
$created_by
|
||||||
),
|
),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
|
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
if ($target_value < 1) {
|
$created_by = max(0, $created_by);
|
||||||
|
if ($target_value < 1 || $created_by < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +851,7 @@ class economic_transfer_queue
|
|||||||
WHERE transfer_type = ?
|
WHERE transfer_type = ?
|
||||||
AND status IN (?, ?)
|
AND status IN (?, ?)
|
||||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||||
|
AND created_by = ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
@@ -750,7 +861,7 @@ class economic_transfer_queue
|
|||||||
|
|
||||||
$queued = self::STATUS_QUEUED;
|
$queued = self::STATUS_QUEUED;
|
||||||
$processing = self::STATUS_PROCESSING;
|
$processing = self::STATUS_PROCESSING;
|
||||||
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
|
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class edge_broker_transport_exception extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function curlErrno(): int
|
||||||
|
{
|
||||||
|
return $this->curlErrno;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class edge_broker_http_exception extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function statusCode(): int
|
||||||
|
{
|
||||||
|
return $this->statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class edge_broker_client
|
||||||
|
{
|
||||||
|
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?string $baseUrl = null,
|
||||||
|
private readonly ?string $sharedSecret = null,
|
||||||
|
private readonly int $timeoutSeconds = 10
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return trim((string)$this->resolveBaseUrl()) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array
|
||||||
|
{
|
||||||
|
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands';
|
||||||
|
$response = $this->request('POST', $url, [
|
||||||
|
'commandType' => $commandType,
|
||||||
|
'payload' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return is_array($response) ? $response : ['ok' => false, 'response' => $response];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateAgent(int $gatewayId, string $agentToken): array
|
||||||
|
{
|
||||||
|
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth';
|
||||||
|
$response = $this->request('POST', $url, [
|
||||||
|
'gatewayId' => $gatewayId,
|
||||||
|
'agentToken' => $agentToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return is_array($response) ? $response : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateShellSession(string $sessionToken): array
|
||||||
|
{
|
||||||
|
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth';
|
||||||
|
$response = $this->request('POST', $url, [
|
||||||
|
'sessionToken' => $sessionToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return is_array($response) ? $response : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array
|
||||||
|
{
|
||||||
|
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close';
|
||||||
|
$response = $this->request('POST', $url, [
|
||||||
|
'sessionToken' => $sessionToken,
|
||||||
|
'transcript' => $transcript,
|
||||||
|
'closedReason' => $closedReason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return is_array($response) ? $response : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBaseUrl(): string
|
||||||
|
{
|
||||||
|
return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSharedSecret(): string
|
||||||
|
{
|
||||||
|
return trim((string)($this->sharedSecret
|
||||||
|
?? getenv('EDGE_BROKER_SHARED_SECRET')
|
||||||
|
?: getenv('EDGE_INTERNAL_SECRET')
|
||||||
|
?: ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function request(string $method, string $url, array $payload): array|object|null
|
||||||
|
{
|
||||||
|
if (trim($url) === '') {
|
||||||
|
throw new Exception('Edge broker URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sharedSecret = $this->resolveSharedSecret();
|
||||||
|
if ($sharedSecret === '') {
|
||||||
|
throw new Exception('Edge broker shared secret is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Edge-Broker-Secret: ' . $sharedSecret,
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$rawResponse = curl_exec($ch);
|
||||||
|
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlErrno = curl_errno($ch);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($rawResponse === false) {
|
||||||
|
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)$rawResponse, true);
|
||||||
|
if ($statusCode >= 400) {
|
||||||
|
$message = is_array($decoded)
|
||||||
|
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
|
||||||
|
: 'Edge broker request failed';
|
||||||
|
throw new edge_broker_http_exception($message, $statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -320,6 +320,16 @@ class invoice_period_flag_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function warmManualFlagsCache(): void
|
public function warmManualFlagsCache(): void
|
||||||
|
{
|
||||||
|
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchActiveManualFlagsFromDb(): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -337,10 +347,7 @@ class invoice_period_flag_service
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return $flags;
|
||||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatStoredFlag(array $row): array
|
private function formatStoredFlag(array $row): array
|
||||||
@@ -388,15 +395,11 @@ class invoice_period_flag_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($flags)) {
|
if (!is_array($flags)) {
|
||||||
// Cache miss — warm on demand and re-fetch
|
// Cache miss — read from the database and refresh Redis without hiding active flags.
|
||||||
$this->warmManualFlagsCache();
|
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||||
try {
|
try {
|
||||||
$flags = (new redis())->get_invoice_period_manual_flags();
|
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!is_array($flags)) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,16 +528,12 @@ class invoice_period_flag_service
|
|||||||
try {
|
try {
|
||||||
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
|
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return [];
|
$flags = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($flags)) {
|
if (!is_array($flags)) {
|
||||||
// Cache miss — enqueue for warming on the next cron run
|
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
|
||||||
try {
|
$this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
|
||||||
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($onlyCustomerNumbers === null) {
|
if ($onlyCustomerNumbers === null) {
|
||||||
@@ -549,17 +548,31 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
|
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
|
||||||
{
|
{
|
||||||
|
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||||
|
$this->cacheAutomaticFlagsForPeriod(
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
$this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||||
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null);
|
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null);
|
||||||
$attributes = $this->getCustomerAttributes(null);
|
$attributes = $this->getCustomerAttributes(null);
|
||||||
|
|
||||||
$flags = array_merge(
|
return array_merge(
|
||||||
$this->detectCustomerRuleViolations($rows, $attributes),
|
$this->detectCustomerRuleViolations($rows, $attributes),
|
||||||
$this->detectPriceMismatches($rows),
|
$this->detectPriceMismatches($rows),
|
||||||
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
|
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
|
||||||
$this->detectVehicleTypeMismatches($rows, $dateFrom),
|
$this->detectVehicleTypeMismatches($rows, $dateFrom),
|
||||||
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
|
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
|
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@@ -603,14 +616,19 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||||
{
|
{
|
||||||
|
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||||
try {
|
try {
|
||||||
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
|
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return [];
|
$rows = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($rows)) {
|
if (!is_array($rows)) {
|
||||||
return [];
|
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||||
|
try {
|
||||||
|
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->seedOrderItemsPreviewCacheFromRows($rows);
|
$this->seedOrderItemsPreviewCacheFromRows($rows);
|
||||||
@@ -627,6 +645,7 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
|
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
|
||||||
{
|
{
|
||||||
|
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||||
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||||
try {
|
try {
|
||||||
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
||||||
@@ -634,6 +653,24 @@ class invoice_period_flag_service
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizePeriodDateRange(string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->normalizePeriodDate($dateFrom, true),
|
||||||
|
$this->normalizePeriodDate($dateTo, false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePeriodDate(string $date, bool $startOfDay): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($date);
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array
|
private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
@@ -1727,18 +1764,7 @@ class invoice_period_flag_service
|
|||||||
if ($currentVehicleType === '' || $expectedVehicleType === '') {
|
if ($currentVehicleType === '' || $expectedVehicleType === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($currentVehicleType === $expectedVehicleType) {
|
return $currentVehicleType === $expectedVehicleType;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Allow a match if one normalized name's tokens are a subset of the other.
|
|
||||||
// E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of
|
|
||||||
// "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type.
|
|
||||||
$currentTokens = explode(' ', $currentVehicleType);
|
|
||||||
$expectedTokens = explode(' ', $expectedVehicleType);
|
|
||||||
if (count($currentTokens) <= count($expectedTokens)) {
|
|
||||||
return array_diff($currentTokens, $expectedTokens) === [];
|
|
||||||
}
|
|
||||||
return array_diff($expectedTokens, $currentTokens) === [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizePrimaryVehicleProductName(string $productName): string
|
private function normalizePrimaryVehicleProductName(string $productName): string
|
||||||
|
|||||||
@@ -340,18 +340,49 @@ class n8n implements n8n_i
|
|||||||
throw new Exception('Webhook target must not be empty.');
|
throw new Exception('Webhook target must not be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
|
|
||||||
return $target;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
|
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
|
||||||
if ($baseUrl === '') {
|
if ($baseUrl === '') {
|
||||||
throw new Exception('n8n webhook base URL is not configured.');
|
throw new Exception('n8n webhook base URL is not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
|
||||||
|
if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) {
|
||||||
|
throw new Exception('Webhook URL must use the configured n8n webhook host.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
|
||||||
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
|
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool
|
||||||
|
{
|
||||||
|
$targetParts = parse_url($targetUrl);
|
||||||
|
$baseParts = parse_url($baseUrl);
|
||||||
|
|
||||||
|
if (!is_array($targetParts) || !is_array($baseParts)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetHost = strtolower((string)($targetParts['host'] ?? ''));
|
||||||
|
$baseHost = strtolower((string)($baseParts['host'] ?? ''));
|
||||||
|
if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetScheme = strtolower((string)($targetParts['scheme'] ?? ''));
|
||||||
|
$baseScheme = strtolower((string)($baseParts['scheme'] ?? ''));
|
||||||
|
if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80));
|
||||||
|
$basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80));
|
||||||
|
|
||||||
|
return $targetPort === $basePort;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class order_reference_suggestions_service
|
|||||||
$rows = [
|
$rows = [
|
||||||
...$this->fetchBookingRows($departmentId, $search),
|
...$this->fetchBookingRows($departmentId, $search),
|
||||||
...$this->fetchOrderRows($departmentId, $search),
|
...$this->fetchOrderRows($departmentId, $search),
|
||||||
...$this->fetchVehicleRows($customerId, $plates, $search),
|
...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
|
||||||
];
|
];
|
||||||
|
|
||||||
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
||||||
@@ -137,7 +137,7 @@ class order_reference_suggestions_service
|
|||||||
* @param array<int, string> $plates
|
* @param array<int, string> $plates
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
|
private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array
|
||||||
{
|
{
|
||||||
$contextWhere = [];
|
$contextWhere = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
@@ -171,6 +171,10 @@ class order_reference_suggestions_service
|
|||||||
$params['search'] = '%' . $this->lower($search) . '%';
|
$params['search'] = '%' . $this->lower($search) . '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$where[] = $this->vehicleDepartmentAccessPredicate();
|
||||||
|
$params['orders_department_id'] = $departmentId;
|
||||||
|
$params['bookings_department_id'] = $departmentId;
|
||||||
|
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
'vehicle' AS source,
|
'vehicle' AS source,
|
||||||
id AS origin_id,
|
id AS origin_id,
|
||||||
@@ -190,6 +194,41 @@ class order_reference_suggestions_service
|
|||||||
return $this->fetchRows($sql, $params);
|
return $this->fetchRows($sql, $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function vehicleDepartmentAccessPredicate(): string
|
||||||
|
{
|
||||||
|
$ordersWhere = [
|
||||||
|
'authorized_orders.department_id = :orders_department_id',
|
||||||
|
'(authorized_orders.customer_id = customer_vehicles.customer_id'
|
||||||
|
. " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||||
|
. " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||||
|
. " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
|
||||||
|
];
|
||||||
|
if ($this->tableHasColumn('orders', 'deleted_at')) {
|
||||||
|
$ordersWhere[] = 'authorized_orders.deleted_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookingsWhere = [
|
||||||
|
'authorized_bookings.department = :bookings_department_id',
|
||||||
|
'(authorized_bookings.customer_number = customer_vehicles.customer_id'
|
||||||
|
. " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||||
|
. " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||||
|
. " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
|
||||||
|
];
|
||||||
|
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
|
||||||
|
$bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '(EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM orders authorized_orders
|
||||||
|
WHERE ' . implode(' AND ', $ordersWhere) . '
|
||||||
|
) OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM order_bookings authorized_bookings
|
||||||
|
WHERE ' . implode(' AND ', $bookingsWhere) . '
|
||||||
|
))';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $params
|
* @param array<string, mixed> $params
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class orders_schema_bootstrap
|
|||||||
|| !self::columnExists($db, 'orders', 'booking_id')
|
|| !self::columnExists($db, 'orders', 'booking_id')
|
||||||
|| !self::columnExists($db, 'orders', 'po')
|
|| !self::columnExists($db, 'orders', 'po')
|
||||||
|| !self::columnExists($db, 'order_bookings', 'po')
|
|| !self::columnExists($db, 'order_bookings', 'po')
|
||||||
|
|| !self::columnExists($db, 'order_bookings', 'customer_number')
|
||||||
|
|| !self::columnExists($db, 'order_bookings', 'department')
|
||||||
|
|| !self::columnExists($db, 'order_bookings', 'deleted_at')
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,6 +68,9 @@ class orders_schema_bootstrap
|
|||||||
$db->query(
|
$db->query(
|
||||||
"UPDATE orders o
|
"UPDATE orders o
|
||||||
INNER JOIN order_bookings b ON b.id = o.booking_id
|
INNER JOIN order_bookings b ON b.id = o.booking_id
|
||||||
|
AND b.customer_number = o.customer_id
|
||||||
|
AND b.department = o.department_id
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
SET o.po = b.po
|
SET o.po = b.po
|
||||||
WHERE o.booking_id IS NOT NULL
|
WHERE o.booking_id IS NOT NULL
|
||||||
AND o.booking_id > 0
|
AND o.booking_id > 0
|
||||||
|
|||||||
@@ -392,7 +392,19 @@ class redis implements redis_i
|
|||||||
|
|
||||||
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
|
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
|
||||||
{
|
{
|
||||||
return $prefix . ':' . $dateFrom . ':' . $dateTo;
|
return $prefix . ':'
|
||||||
|
. $this->normalizeInvoicePeriodCacheDate($dateFrom, true) . ':'
|
||||||
|
. $this->normalizeInvoicePeriodCacheDate($dateTo, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeInvoicePeriodCacheDate(string $date, bool $startOfDay): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($date);
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function workfeedEmployeeNameCacheKey(string $employeeId): string
|
private function workfeedEmployeeNameCacheKey(string $employeeId): string
|
||||||
@@ -512,7 +524,11 @@ class redis implements redis_i
|
|||||||
*/
|
*/
|
||||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
|
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
|
||||||
{
|
{
|
||||||
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]);
|
$this->get_client()->sadd('invoice_period_warming_queue', [
|
||||||
|
$this->normalizeInvoicePeriodCacheDate($dateFrom, true)
|
||||||
|
. '|'
|
||||||
|
. $this->normalizeInvoicePeriodCacheDate($dateTo, false),
|
||||||
|
]);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
|
|||||||
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
channel_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
app VARCHAR(16) NOT NULL,
|
||||||
|
repository VARCHAR(255) NOT NULL,
|
||||||
|
branch VARCHAR(128) NOT NULL,
|
||||||
|
commit_sha VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
source VARCHAR(64) NULL,
|
||||||
|
workflow_url VARCHAR(512) NULL,
|
||||||
|
gate_operation_id BIGINT UNSIGNED NULL,
|
||||||
|
sync_operation_id BIGINT UNSIGNED NULL,
|
||||||
|
deployment_id BIGINT UNSIGNED NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
metadata_json LONGTEXT NULL,
|
||||||
|
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
gate_passed_at DATETIME NULL,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
promoted_at DATETIME NULL,
|
||||||
|
failed_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
|
||||||
|
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
|
||||||
|
INDEX idx_release_auto_sync_gate (gate_operation_id),
|
||||||
|
INDEX idx_release_auto_sync_sync (sync_operation_id),
|
||||||
|
INDEX idx_release_auto_sync_deployment (deployment_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
channel_id BIGINT UNSIGNED NULL,
|
channel_id BIGINT UNSIGNED NULL,
|
||||||
@@ -157,6 +186,7 @@ class release_manager_schema_bootstrap
|
|||||||
bundle_id BIGINT UNSIGNED NULL,
|
bundle_id BIGINT UNSIGNED NULL,
|
||||||
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
|
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
|
||||||
app VARCHAR(16) NOT NULL,
|
app VARCHAR(16) NOT NULL,
|
||||||
|
active_channel_app_key VARCHAR(96) NULL,
|
||||||
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
|
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
|
||||||
repository VARCHAR(255) NULL,
|
repository VARCHAR(255) NULL,
|
||||||
branch VARCHAR(128) NULL,
|
branch VARCHAR(128) NULL,
|
||||||
@@ -297,6 +327,47 @@ class release_manager_schema_bootstrap
|
|||||||
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
|
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS release_operation_runs (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(64) NOT NULL,
|
||||||
|
subject_type VARCHAR(64) NULL,
|
||||||
|
subject_id VARCHAR(128) NULL,
|
||||||
|
channel_id BIGINT UNSIGNED NULL,
|
||||||
|
app VARCHAR(16) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||||
|
title VARCHAR(255) NULL,
|
||||||
|
summary TEXT NULL,
|
||||||
|
solution_hint TEXT NULL,
|
||||||
|
actor_user_id INT NULL,
|
||||||
|
context_json LONGTEXT NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_release_operation_runs_channel (channel_id, created_at),
|
||||||
|
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
|
||||||
|
INDEX idx_release_operation_runs_type_status (operation_type, status),
|
||||||
|
INDEX idx_release_operation_runs_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS release_operation_steps (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_run_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
step_key VARCHAR(64) NOT NULL,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||||
|
message TEXT NULL,
|
||||||
|
diagnostic TEXT NULL,
|
||||||
|
solution_hint TEXT NULL,
|
||||||
|
context_json LONGTEXT NULL,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_release_operation_steps_run (operation_run_id, id),
|
||||||
|
INDEX idx_release_operation_steps_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS release_audit_logs (
|
"CREATE TABLE IF NOT EXISTS release_audit_logs (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
channel_id BIGINT UNSIGNED NULL,
|
channel_id BIGINT UNSIGNED NULL,
|
||||||
@@ -321,6 +392,7 @@ class release_manager_schema_bootstrap
|
|||||||
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
|
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
|
||||||
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
||||||
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
|
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
|
||||||
|
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
|
||||||
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
||||||
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
||||||
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
||||||
@@ -336,9 +408,12 @@ class release_manager_schema_bootstrap
|
|||||||
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
||||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
||||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
||||||
|
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
|
||||||
|
|
||||||
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
|
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
|
||||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
|
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
|
||||||
|
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
|
||||||
|
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
|
||||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
|
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
|
||||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
|
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
|
||||||
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
|
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
|
||||||
@@ -362,9 +437,12 @@ class release_manager_schema_bootstrap
|
|||||||
'release_versions',
|
'release_versions',
|
||||||
'release_channel_versions',
|
'release_channel_versions',
|
||||||
'release_assignments',
|
'release_assignments',
|
||||||
|
'release_auto_sync_events',
|
||||||
'release_service_sets',
|
'release_service_sets',
|
||||||
'release_deployments',
|
'release_deployments',
|
||||||
'release_bundles',
|
'release_bundles',
|
||||||
|
'release_operation_runs',
|
||||||
|
'release_operation_steps',
|
||||||
'release_timeline_sessions',
|
'release_timeline_sessions',
|
||||||
'release_timeline_events',
|
'release_timeline_events',
|
||||||
] as $table) {
|
] as $table) {
|
||||||
@@ -457,4 +535,23 @@ class release_manager_schema_bootstrap
|
|||||||
|
|
||||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||||
|
if ($table === '' || $index === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexSql = $db->escape_string($index);
|
||||||
|
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||||
|
if ($result !== false && $result->num_rows > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
|
|||||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
department_id INT NOT NULL,
|
||||||
|
lane_id INT NULL,
|
||||||
|
vehicle_type_id INT NULL,
|
||||||
|
config_version_id INT NULL,
|
||||||
|
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||||
|
path_signature VARCHAR(128) NOT NULL,
|
||||||
|
result_signature VARCHAR(128) NOT NULL,
|
||||||
|
answers_json JSON NOT NULL,
|
||||||
|
result_json JSON NOT NULL,
|
||||||
|
scope_json JSON NULL,
|
||||||
|
confirmed_by INT NULL,
|
||||||
|
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
stale_reason VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
|
||||||
|
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($queries as $sql) {
|
foreach ($queries as $sql) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class shelly_relay_inventory
|
|||||||
* @return array<int,array<string,mixed>>
|
* @return array<int,array<string,mixed>>
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function listRelayOptions(): array
|
public function listRelayOptions(bool $include_sensitive_network_details = false): array
|
||||||
{
|
{
|
||||||
$devices_status = $this->fetchOwnedDevicesStatus();
|
$devices_status = $this->fetchOwnedDevicesStatus();
|
||||||
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
||||||
@@ -49,7 +49,8 @@ class shelly_relay_inventory
|
|||||||
|
|
||||||
$option = $this->buildRelayOption(
|
$option = $this->buildRelayOption(
|
||||||
$normalized_device,
|
$normalized_device,
|
||||||
is_array($catalog_entry) ? $catalog_entry : null
|
is_array($catalog_entry) ? $catalog_entry : null,
|
||||||
|
$include_sensitive_network_details
|
||||||
);
|
);
|
||||||
if ($option === null) {
|
if ($option === null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -160,7 +161,11 @@ class shelly_relay_inventory
|
|||||||
* @param array<string,mixed> $device
|
* @param array<string,mixed> $device
|
||||||
* @return array<string,mixed>|null
|
* @return array<string,mixed>|null
|
||||||
*/
|
*/
|
||||||
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
|
private function buildRelayOption(
|
||||||
|
array $device,
|
||||||
|
?array $catalog_entry = null,
|
||||||
|
bool $include_sensitive_network_details = false
|
||||||
|
): ?array
|
||||||
{
|
{
|
||||||
if ($device === [] || !$this->isRelayCapableDevice($device)) {
|
if ($device === [] || !$this->isRelayCapableDevice($device)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -203,9 +208,8 @@ class shelly_relay_inventory
|
|||||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||||
}
|
}
|
||||||
$status_color = $this->extractStatusColor($online);
|
$status_color = $this->extractStatusColor($online);
|
||||||
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
|
||||||
|
|
||||||
return [
|
$option = [
|
||||||
'id' => $device_id,
|
'id' => $device_id,
|
||||||
'name' => $this->buildRelayLabel(
|
'name' => $this->buildRelayLabel(
|
||||||
$device_type,
|
$device_type,
|
||||||
@@ -223,10 +227,15 @@ class shelly_relay_inventory
|
|||||||
'device_generation' => $device_generation,
|
'device_generation' => $device_generation,
|
||||||
'control_type' => $control_type,
|
'control_type' => $control_type,
|
||||||
'control_name' => $control_name !== '' ? $control_name : null,
|
'control_name' => $control_name !== '' ? $control_name : null,
|
||||||
'local_ip' => $local_ip,
|
|
||||||
'status_color' => $status_color,
|
'status_color' => $status_color,
|
||||||
'online' => $online,
|
'online' => $online,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($include_sensitive_network_details) {
|
||||||
|
$option['local_ip'] = $this->extractLocalIp($device, $catalog_entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $option;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class system_search_service
|
|||||||
$allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? []));
|
$allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? []));
|
||||||
$ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? []));
|
$ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? []));
|
||||||
$ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null;
|
$ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null;
|
||||||
|
$allowedDepartmentIds = array_values(array_unique(array_map('intval', (array)($options['allowed_department_ids'] ?? []))));
|
||||||
$permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []);
|
$permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []);
|
||||||
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
||||||
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
||||||
@@ -107,6 +108,7 @@ class system_search_service
|
|||||||
'offset' => $offset,
|
'offset' => $offset,
|
||||||
'own' => $ownCustomerNumber,
|
'own' => $ownCustomerNumber,
|
||||||
'own_only' => $ownOnlyTypes,
|
'own_only' => $ownOnlyTypes,
|
||||||
|
'dept' => $allowedDepartmentIds,
|
||||||
'assoc' => $includeAssociations,
|
'assoc' => $includeAssociations,
|
||||||
'dbg' => $debugIntent,
|
'dbg' => $debugIntent,
|
||||||
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
||||||
@@ -130,7 +132,8 @@ class system_search_service
|
|||||||
$ownCustomerNumber,
|
$ownCustomerNumber,
|
||||||
$permissionsCatalogAll,
|
$permissionsCatalogAll,
|
||||||
$permissionsCatalogOwn,
|
$permissionsCatalogOwn,
|
||||||
$moduleConfigVisibility
|
$moduleConfigVisibility,
|
||||||
|
$allowedDepartmentIds
|
||||||
);
|
);
|
||||||
|
|
||||||
$intentAssociationHint = false;
|
$intentAssociationHint = false;
|
||||||
@@ -180,7 +183,8 @@ class system_search_service
|
|||||||
$ownCustomerNumber,
|
$ownCustomerNumber,
|
||||||
$permissionsCatalogAll,
|
$permissionsCatalogAll,
|
||||||
$permissionsCatalogOwn,
|
$permissionsCatalogOwn,
|
||||||
$moduleConfigVisibility
|
$moduleConfigVisibility,
|
||||||
|
$allowedDepartmentIds
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$intentMeta['status'] = 'fallback';
|
$intentMeta['status'] = 'fallback';
|
||||||
@@ -207,25 +211,29 @@ class system_search_service
|
|||||||
$activeTypes,
|
$activeTypes,
|
||||||
$this->associationEntityTypes()
|
$this->associationEntityTypes()
|
||||||
));
|
));
|
||||||
foreach ($customerNumbers as $customerNumber) {
|
$associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes));
|
||||||
$associated = $this->executeLexicalSearch(
|
if (!empty($associationTypes)) {
|
||||||
$associationTypes,
|
foreach ($customerNumbers as $customerNumber) {
|
||||||
[(string)$customerNumber],
|
$associated = $this->executeLexicalSearch(
|
||||||
[],
|
$associationTypes,
|
||||||
$ownOnlyTypes,
|
[(string)$customerNumber],
|
||||||
$ownCustomerNumber,
|
[],
|
||||||
$permissionsCatalogAll,
|
$ownOnlyTypes,
|
||||||
$permissionsCatalogOwn,
|
$ownCustomerNumber,
|
||||||
$moduleConfigVisibility,
|
$permissionsCatalogAll,
|
||||||
[$customerNumber]
|
$permissionsCatalogOwn,
|
||||||
);
|
$moduleConfigVisibility,
|
||||||
foreach ($associated as &$item) {
|
$allowedDepartmentIds,
|
||||||
if (!isset($item['association_reason'])) {
|
[$customerNumber]
|
||||||
$item['association_reason'] = 'customer:' . $customerNumber;
|
);
|
||||||
|
foreach ($associated as &$item) {
|
||||||
|
if (!isset($item['association_reason'])) {
|
||||||
|
$item['association_reason'] = 'customer:' . $customerNumber;
|
||||||
|
}
|
||||||
|
$item['score'] = max((int)$item['score'], 35);
|
||||||
}
|
}
|
||||||
$item['score'] = max((int)$item['score'], 35);
|
$initialResults = $this->mergeResults($initialResults, $associated);
|
||||||
}
|
}
|
||||||
$initialResults = $this->mergeResults($initialResults, $associated);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,6 +312,7 @@ class system_search_service
|
|||||||
* @param array<string, string> $permissionsCatalogAll
|
* @param array<string, string> $permissionsCatalogAll
|
||||||
* @param array<int, string> $permissionsCatalogOwn
|
* @param array<int, string> $permissionsCatalogOwn
|
||||||
* @param array<string, bool> $moduleConfigVisibility
|
* @param array<string, bool> $moduleConfigVisibility
|
||||||
|
* @param array<int, int> $allowedDepartmentIds
|
||||||
* @param array<int, int> $forcedCustomerNumbers
|
* @param array<int, int> $forcedCustomerNumbers
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -316,6 +325,7 @@ class system_search_service
|
|||||||
array $permissionsCatalogAll,
|
array $permissionsCatalogAll,
|
||||||
array $permissionsCatalogOwn,
|
array $permissionsCatalogOwn,
|
||||||
array $moduleConfigVisibility,
|
array $moduleConfigVisibility,
|
||||||
|
array $allowedDepartmentIds = [],
|
||||||
array $forcedCustomerNumbers = []
|
array $forcedCustomerNumbers = []
|
||||||
): array {
|
): array {
|
||||||
$results = [];
|
$results = [];
|
||||||
@@ -334,6 +344,7 @@ class system_search_service
|
|||||||
$ownOnly,
|
$ownOnly,
|
||||||
$ownCustomerNumber,
|
$ownCustomerNumber,
|
||||||
$moduleConfigVisibility,
|
$moduleConfigVisibility,
|
||||||
|
$allowedDepartmentIds,
|
||||||
$forcedCustomerNumbers
|
$forcedCustomerNumbers
|
||||||
);
|
);
|
||||||
if (empty($rows)) {
|
if (empty($rows)) {
|
||||||
@@ -346,6 +357,7 @@ class system_search_service
|
|||||||
$permissionsCatalogAll,
|
$permissionsCatalogAll,
|
||||||
$permissionsCatalogOwn,
|
$permissionsCatalogOwn,
|
||||||
$moduleConfigVisibility,
|
$moduleConfigVisibility,
|
||||||
|
$allowedDepartmentIds,
|
||||||
$forcedCustomerNumbers
|
$forcedCustomerNumbers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -359,6 +371,7 @@ class system_search_service
|
|||||||
$permissionsCatalogAll,
|
$permissionsCatalogAll,
|
||||||
$permissionsCatalogOwn,
|
$permissionsCatalogOwn,
|
||||||
$moduleConfigVisibility,
|
$moduleConfigVisibility,
|
||||||
|
$allowedDepartmentIds,
|
||||||
$forcedCustomerNumbers
|
$forcedCustomerNumbers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -372,6 +385,7 @@ class system_search_service
|
|||||||
* @param array<string, string> $permissionsCatalogAll
|
* @param array<string, string> $permissionsCatalogAll
|
||||||
* @param array<int, string> $permissionsCatalogOwn
|
* @param array<int, string> $permissionsCatalogOwn
|
||||||
* @param array<string, bool> $moduleConfigVisibility
|
* @param array<string, bool> $moduleConfigVisibility
|
||||||
|
* @param array<int, int> $allowedDepartmentIds
|
||||||
* @param array<int, int> $forcedCustomerNumbers
|
* @param array<int, int> $forcedCustomerNumbers
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -384,6 +398,7 @@ class system_search_service
|
|||||||
array $permissionsCatalogAll,
|
array $permissionsCatalogAll,
|
||||||
array $permissionsCatalogOwn,
|
array $permissionsCatalogOwn,
|
||||||
array $moduleConfigVisibility,
|
array $moduleConfigVisibility,
|
||||||
|
array $allowedDepartmentIds,
|
||||||
array $forcedCustomerNumbers
|
array $forcedCustomerNumbers
|
||||||
): array {
|
): array {
|
||||||
if ($this->isGenericEntityType($entityType)) {
|
if ($this->isGenericEntityType($entityType)) {
|
||||||
@@ -393,6 +408,7 @@ class system_search_service
|
|||||||
$entityBoost,
|
$entityBoost,
|
||||||
$ownOnly,
|
$ownOnly,
|
||||||
$ownCustomerNumber,
|
$ownCustomerNumber,
|
||||||
|
$allowedDepartmentIds,
|
||||||
$forcedCustomerNumbers
|
$forcedCustomerNumbers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -439,9 +455,24 @@ class system_search_service
|
|||||||
return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType)));
|
return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function indexedEntitySupportsDepartmentFilter(string $entityType): bool
|
||||||
|
{
|
||||||
|
$entityType = trim(mb_strtolower($entityType));
|
||||||
|
if (in_array($entityType, ['orders', 'objects'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = system_search_registry::genericEntityConfigs()[$entityType] ?? null;
|
||||||
|
return is_array($config)
|
||||||
|
&& isset($config['department_field'])
|
||||||
|
&& is_string($config['department_field'])
|
||||||
|
&& trim($config['department_field']) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $terms
|
* @param array<int, string> $terms
|
||||||
* @param array<string, bool> $moduleConfigVisibility
|
* @param array<string, bool> $moduleConfigVisibility
|
||||||
|
* @param array<int, int> $allowedDepartmentIds
|
||||||
* @param array<int, int> $forcedCustomerNumbers
|
* @param array<int, int> $forcedCustomerNumbers
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -452,6 +483,7 @@ class system_search_service
|
|||||||
bool $ownOnly,
|
bool $ownOnly,
|
||||||
?int $ownCustomerNumber,
|
?int $ownCustomerNumber,
|
||||||
array $moduleConfigVisibility,
|
array $moduleConfigVisibility,
|
||||||
|
array $allowedDepartmentIds,
|
||||||
array $forcedCustomerNumbers
|
array $forcedCustomerNumbers
|
||||||
): array {
|
): array {
|
||||||
global $db;
|
global $db;
|
||||||
@@ -473,6 +505,9 @@ class system_search_service
|
|||||||
if (!empty($customerNumbers)) {
|
if (!empty($customerNumbers)) {
|
||||||
$wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")";
|
$wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")";
|
||||||
}
|
}
|
||||||
|
if (!empty($allowedDepartmentIds) && $this->indexedEntitySupportsDepartmentFilter($entityType)) {
|
||||||
|
$wheres[] = "`department_id` IN (" . implode(',', array_map('intval', $allowedDepartmentIds)) . ")";
|
||||||
|
}
|
||||||
|
|
||||||
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
|
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
|
||||||
$rows = [];
|
$rows = [];
|
||||||
@@ -1488,6 +1523,7 @@ class system_search_service
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $terms
|
* @param array<int, string> $terms
|
||||||
|
* @param array<int, int> $allowedDepartmentIds
|
||||||
* @param array<int, int> $forcedCustomerNumbers
|
* @param array<int, int> $forcedCustomerNumbers
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -1497,6 +1533,7 @@ class system_search_service
|
|||||||
int $entityBoost,
|
int $entityBoost,
|
||||||
bool $ownOnly,
|
bool $ownOnly,
|
||||||
?int $ownCustomerNumber,
|
?int $ownCustomerNumber,
|
||||||
|
array $allowedDepartmentIds = [],
|
||||||
array $forcedCustomerNumbers = []
|
array $forcedCustomerNumbers = []
|
||||||
): array {
|
): array {
|
||||||
if (empty($terms)) {
|
if (empty($terms)) {
|
||||||
@@ -1602,7 +1639,9 @@ class system_search_service
|
|||||||
$customerNumbers,
|
$customerNumbers,
|
||||||
$customerField,
|
$customerField,
|
||||||
$customerFieldMode,
|
$customerFieldMode,
|
||||||
$fixedConditions
|
$fixedConditions,
|
||||||
|
$allowedDepartmentIds,
|
||||||
|
$departmentField
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->primeCustomerContexts(array_values(array_unique(array_filter(
|
$this->primeCustomerContexts(array_values(array_unique(array_filter(
|
||||||
@@ -1798,6 +1837,8 @@ class system_search_service
|
|||||||
* @param string|null $customerField
|
* @param string|null $customerField
|
||||||
* @param string $customerFieldMode
|
* @param string $customerFieldMode
|
||||||
* @param array<string, mixed> $fixedConditions
|
* @param array<string, mixed> $fixedConditions
|
||||||
|
* @param array<int, int> $departmentIds
|
||||||
|
* @param string|null $departmentField
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function searchTable(
|
private function searchTable(
|
||||||
@@ -1808,7 +1849,9 @@ class system_search_service
|
|||||||
array $customerNumbers = [],
|
array $customerNumbers = [],
|
||||||
?string $customerField = null,
|
?string $customerField = null,
|
||||||
string $customerFieldMode = 'default',
|
string $customerFieldMode = 'default',
|
||||||
array $fixedConditions = []
|
array $fixedConditions = [],
|
||||||
|
array $departmentIds = [],
|
||||||
|
?string $departmentField = null
|
||||||
): array {
|
): array {
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -1844,6 +1887,10 @@ class system_search_service
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($departmentIds) && $departmentField !== null && in_array($departmentField, $fields, true)) {
|
||||||
|
$wheres[] = "`$departmentField` IN (" . implode(',', array_map('intval', $departmentIds)) . ")";
|
||||||
|
}
|
||||||
|
|
||||||
$termClauses = [];
|
$termClauses = [];
|
||||||
foreach ($terms as $term) {
|
foreach ($terms as $term) {
|
||||||
$escaped = $db->escape_string($term);
|
$escaped = $db->escape_string($term);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require_once WD . '/modules/workfeed/workfeed_c.php';
|
|||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use interfaces\workfeed_i;
|
use interfaces\workfeed_i;
|
||||||
|
use workfeed\config\workfeed_api_url_c;
|
||||||
use workfeed\workfeed_c;
|
use workfeed\workfeed_c;
|
||||||
|
|
||||||
class workfeed implements workfeed_i
|
class workfeed implements workfeed_i
|
||||||
@@ -84,7 +85,7 @@ class workfeed implements workfeed_i
|
|||||||
$companyId = $this->requireConfiguredCompanyId();
|
$companyId = $this->requireConfiguredCompanyId();
|
||||||
|
|
||||||
$url = $this->buildUrl(
|
$url = $this->buildUrl(
|
||||||
$this->config->api_url->getVariableValue(),
|
workfeed_api_url_c::normalizeApiUrlForValidation((string)$this->config->api_url->getVariableValue()),
|
||||||
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
|
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
|
||||||
$query
|
$query
|
||||||
);
|
);
|
||||||
@@ -182,7 +183,10 @@ class workfeed implements workfeed_i
|
|||||||
private function requireConfiguredApiUrl(): void
|
private function requireConfiguredApiUrl(): void
|
||||||
{
|
{
|
||||||
$url = trim((string)$this->config->api_url->getVariableValue());
|
$url = trim((string)$this->config->api_url->getVariableValue());
|
||||||
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
|
if ($url === ''
|
||||||
|
|| filter_var(workfeed_api_url_c::normalizeApiUrlForValidation($url), FILTER_VALIDATE_URL) === false
|
||||||
|
|| !workfeed_api_url_c::isTrustedApiUrl($url)
|
||||||
|
) {
|
||||||
throw new Exception('Invalid Workfeed API URL configured.');
|
throw new Exception('Invalid Workfeed API URL configured.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,15 +214,6 @@ class workfeed implements workfeed_i
|
|||||||
return $companyId;
|
return $companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeUrlForValidation(string $url): string
|
|
||||||
{
|
|
||||||
if (preg_match('#^https?://#i', $url)) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://' . ltrim($url, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ $isPreview = $_GET['preview'] ?? false;
|
|||||||
// Remove query string if present
|
// Remove query string if present
|
||||||
$file = strtok($file, '?');
|
$file = strtok($file, '?');
|
||||||
|
|
||||||
|
// Require authentication for direct /files/ access
|
||||||
|
if (str_contains($file, '/files/')) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$token = $_GET['token'] ?? $_POST['token'] ?? ($headers['Authorization'] ?? null);
|
||||||
|
if (!empty($token)) {
|
||||||
|
$token = str_replace('Bearer ', '', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($token) || !(new \classes\authentication())->validate_token($token)) {
|
||||||
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
echo 'Unauthorized';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$isPDF = false;
|
$isPDF = false;
|
||||||
$isPDFStore = false;
|
$isPDFStore = false;
|
||||||
$isAttachment = false;
|
$isAttachment = false;
|
||||||
@@ -40,20 +55,6 @@ if ($isPDF && $isPDFStore) {
|
|||||||
|
|
||||||
// Check if the certificate exists
|
// Check if the certificate exists
|
||||||
if (!$wash_certificate_store->isFileInStore($file)) {
|
if (!$wash_certificate_store->isFileInStore($file)) {
|
||||||
// Try the PDF store
|
|
||||||
$pdf_store = new \classes\pdf_store();
|
|
||||||
if ($pdf_store->isFileInStore(str_replace('/files/', '', $file))) {
|
|
||||||
// Download the certificate from the PDF store to /tmp
|
|
||||||
$certificate_path = $pdf_store->download(str_replace('/files/', '', $file));
|
|
||||||
// Send the certificate to the client
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: inline; filename="' . str_replace('/files/', '', $file) . '"');
|
|
||||||
header('Content-Length: ' . filesize($certificate_path));
|
|
||||||
readfile($certificate_path);
|
|
||||||
// Delete the certificate from /tmp after sending it
|
|
||||||
unlink($certificate_path);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
echo 'Certificate not found in store' . $file;
|
echo 'Certificate not found in store' . $file;
|
||||||
//header('HTTP/1.1 404 Not Found');
|
//header('HTTP/1.1 404 Not Found');
|
||||||
exit;
|
exit;
|
||||||
@@ -118,4 +119,4 @@ if (!$isPDF) {
|
|||||||
// Delete the file from /tmp after sending it
|
// Delete the file from /tmp after sending it
|
||||||
unlink($file_path);
|
unlink($file_path);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,30 +10,18 @@ const WD = __DIR__;
|
|||||||
|
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
require_once 'config.php';
|
require_once 'config.php';
|
||||||
|
require_once __DIR__ . '/classes/cors_policy.php';
|
||||||
|
|
||||||
/** CORS */
|
/** CORS */
|
||||||
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
||||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
|
||||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
|
||||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
|
||||||
header("Access-Control-Allow-Credentials: true");
|
|
||||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
|
||||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||||
header("Access-Control-Allow-Credentials: true");
|
http_response_code($preflight['status']);
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
echo $preflight['body'];
|
||||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
exit;
|
||||||
header('Content-Type: application/json');
|
|
||||||
http_response_code(200);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
|
||||||
/** Debug */
|
/** Debug */
|
||||||
if ($DEBUG) {
|
if ($DEBUG) {
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
@@ -73,6 +61,17 @@ try {
|
|||||||
spl_autoload_register(function (string $class): void {
|
spl_autoload_register(function (string $class): void {
|
||||||
$class = ltrim($class, '\\');
|
$class = ltrim($class, '\\');
|
||||||
$cache_key = 'autoload:' . $class;
|
$cache_key = 'autoload:' . $class;
|
||||||
|
$wdReal = rtrim((string) realpath(WD), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||||
|
$modulesRoot = $wdReal . 'modules' . DIRECTORY_SEPARATOR;
|
||||||
|
$isPathInside = static function (string $path, string $root): bool {
|
||||||
|
$resolved = realpath($path);
|
||||||
|
if ($resolved === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = rtrim($resolved, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||||
|
return str_starts_with($resolved, $root);
|
||||||
|
};
|
||||||
$is_loaded = static function (string $candidate): bool {
|
$is_loaded = static function (string $candidate): bool {
|
||||||
return class_exists($candidate, false)
|
return class_exists($candidate, false)
|
||||||
|| interface_exists($candidate, false)
|
|| interface_exists($candidate, false)
|
||||||
@@ -85,11 +84,13 @@ spl_autoload_register(function (string $class): void {
|
|||||||
try {
|
try {
|
||||||
$cached = redis->get($cache_key);
|
$cached = redis->get($cache_key);
|
||||||
if (is_string($cached) && $cached !== '' && is_file($cached)) {
|
if (is_string($cached) && $cached !== '' && is_file($cached)) {
|
||||||
require_once $cached;
|
if ($isPathInside($cached, $wdReal)) {
|
||||||
if ($is_loaded($class)) {
|
require_once $cached;
|
||||||
return;
|
if ($is_loaded($class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Stale class mapping in cache, continue with normal lookup.
|
// Stale, invalid, or unsafe class mapping in cache; continue with normal lookup.
|
||||||
redis->delete($cache_key);
|
redis->delete($cache_key);
|
||||||
} elseif (is_string($cached) && $cached !== '') {
|
} elseif (is_string($cached) && $cached !== '') {
|
||||||
// Remove non-existing cached path to avoid repeated failed lookups.
|
// Remove non-existing cached path to avoid repeated failed lookups.
|
||||||
@@ -132,6 +133,18 @@ spl_autoload_register(function (string $class): void {
|
|||||||
$module_dirs = redis->get_array('autoload:module_dirs');
|
$module_dirs = redis->get_array('autoload:module_dirs');
|
||||||
} catch (\Throwable $e) {}
|
} catch (\Throwable $e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_array($module_dirs)) {
|
||||||
|
$module_dirs = array_values(array_filter($module_dirs, static function ($item) use ($base, $modulesRoot, $isPathInside): bool {
|
||||||
|
if (!is_string($item) || $item === '' || str_contains($item, DIRECTORY_SEPARATOR) || str_contains($item, '..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $base . 'modules' . DIRECTORY_SEPARATOR . $item;
|
||||||
|
return is_dir($candidate) && $isPathInside($candidate, $modulesRoot);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if ($module_dirs === null) {
|
if ($module_dirs === null) {
|
||||||
$module_dirs = array_filter(scandir($base . 'modules'), function($item) use ($base) {
|
$module_dirs = array_filter(scandir($base . 'modules'), function($item) use ($base) {
|
||||||
return $item !== '.' && $item !== '..' && is_dir($base . 'modules' . DIRECTORY_SEPARATOR . $item);
|
return $item !== '.' && $item !== '..' && is_dir($base . 'modules' . DIRECTORY_SEPARATOR . $item);
|
||||||
@@ -249,11 +262,6 @@ if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the route ends with .php, then require the file_server.php
|
|
||||||
if (str_contains($_SERVER['REQUEST_URI'], '.pdf')) {
|
|
||||||
require_once 'file_server.php';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
// If the route ends with a MIME type, then require the file_server.php
|
// If the route ends with a MIME type, then require the file_server.php
|
||||||
if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
|
if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
|
||||||
require_once 'file_server.php';
|
require_once 'file_server.php';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace attachments\helpers;
|
|||||||
class attachment_content
|
class attachment_content
|
||||||
{
|
{
|
||||||
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
|
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
|
||||||
|
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
|
||||||
public ?string $image; // Used to store the attachment object name, in the attachment store.
|
public ?string $image; // Used to store the attachment object name, in the attachment store.
|
||||||
public ?string $document; // Used to store the attachment object name, in the attachment store.
|
public ?string $document; // Used to store the attachment object name, in the attachment store.
|
||||||
public ?attachment_relation $relation; // Used to store the attachment relation object.
|
public ?attachment_relation $relation; // Used to store the attachment relation object.
|
||||||
@@ -49,4 +50,4 @@ class attachment_content
|
|||||||
$this->relation = $relation;
|
$this->relation = $relation;
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
|
|||||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||||
const BUTTON_RESET = 'reset';
|
const BUTTON_RESET = 'reset';
|
||||||
const BUTTON_START = 'start';
|
const BUTTON_START = 'start';
|
||||||
|
const BUTTON_PROGRAM_PICKER = 'program_picker';
|
||||||
// Thumb
|
// Thumb
|
||||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||||
public int $thumb_size = 1550; // height and width of the thumb
|
public int $thumb_size = 1550; // height and width of the thumb
|
||||||
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
|
|||||||
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
|
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
|
||||||
$this->drawProgramWheel();
|
$this->drawProgramWheel();
|
||||||
$this->drawThumb();
|
$this->drawThumb();
|
||||||
$this->drawStepThumb();
|
|
||||||
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
||||||
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
||||||
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
||||||
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
|
|||||||
if (is_string($button)) {
|
if (is_string($button)) {
|
||||||
$trimmed = trim($button);
|
$trimmed = trim($button);
|
||||||
$specialButton = strtolower($trimmed);
|
$specialButton = strtolower($trimmed);
|
||||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
|
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
|
||||||
return $specialButton;
|
return $specialButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
|
|||||||
|
|
||||||
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
||||||
{
|
{
|
||||||
|
if ($button === self::BUTTON_PROGRAM_PICKER) {
|
||||||
|
return $this->getProgramPickerStepCoordinates();
|
||||||
|
}
|
||||||
|
|
||||||
if ($button === self::BUTTON_RESET) {
|
if ($button === self::BUTTON_RESET) {
|
||||||
return [
|
return [
|
||||||
'x' => 1736,
|
'x' => 1736,
|
||||||
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
|
|||||||
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getProgramPickerStepCoordinates(): array
|
||||||
|
{
|
||||||
|
$thumbX = 650;
|
||||||
|
$thumbY = 1290;
|
||||||
|
$stepSize = 350;
|
||||||
|
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
|
||||||
|
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
|
||||||
|
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
|
||||||
|
$angle = $this->getRotationThumbPosition();
|
||||||
|
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
|
||||||
|
$rad = deg2rad($angle);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
|
||||||
|
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
|
||||||
|
'size' => $stepSize,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3426,7 +3426,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions();
|
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(true);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$this->shellyRelayOptionsCache = [];
|
$this->shellyRelayOptionsCache = [];
|
||||||
}
|
}
|
||||||
@@ -4021,7 +4021,7 @@ BASH;
|
|||||||
{
|
{
|
||||||
$configured = $this->configuredBrokerSharedSecret();
|
$configured = $this->configuredBrokerSharedSecret();
|
||||||
if ($configured === '') {
|
if ($configured === '') {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $secret !== null && hash_equals($configured, trim($secret));
|
return $secret !== null && hash_equals($configured, trim($secret));
|
||||||
@@ -4036,12 +4036,8 @@ BASH;
|
|||||||
$target = strtolower(trim((string)($options['target'] ?? 'all')));
|
$target = strtolower(trim((string)($options['target'] ?? 'all')));
|
||||||
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
|
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
|
||||||
|
|
||||||
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl(
|
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl($this->configuredBrokerInternalUrl());
|
||||||
array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl()
|
$publicConfigured = $this->configuredPublicBrokerUrl();
|
||||||
);
|
|
||||||
$publicConfigured = array_key_exists('public_broker_url', $options)
|
|
||||||
? trim((string)$options['public_broker_url'])
|
|
||||||
: $this->configuredPublicBrokerUrl();
|
|
||||||
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
|
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
|
||||||
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
|
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
|
||||||
);
|
);
|
||||||
@@ -4133,7 +4129,7 @@ BASH;
|
|||||||
|
|
||||||
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
|
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
|
||||||
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
|
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
|
||||||
return array_merge($health, [
|
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'status' => 'connected',
|
'status' => 'connected',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4142,7 +4138,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
|
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
|
||||||
return array_merge($health, [
|
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'status' => 'connected_legacy',
|
'status' => 'connected_legacy',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4151,7 +4147,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($health['status_code'] ?? null) !== null) {
|
if (($health['status_code'] ?? null) !== null) {
|
||||||
return array_merge($health, [
|
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'unexpected_response',
|
'status' => 'unexpected_response',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4159,7 +4155,7 @@ BASH;
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($health, [
|
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'unreachable',
|
'status' => 'unreachable',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4187,7 +4183,7 @@ BASH;
|
|||||||
|
|
||||||
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
|
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
|
||||||
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
|
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
|
||||||
return array_merge($diagnostic, [
|
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'status' => $required ? 'validated' : 'not_required',
|
'status' => $required ? 'validated' : 'not_required',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4198,7 +4194,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($diagnostic['status_code'] ?? null) === 403) {
|
if (($diagnostic['status_code'] ?? null) === 403) {
|
||||||
return array_merge($diagnostic, [
|
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'secret_rejected',
|
'status' => 'secret_rejected',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4211,7 +4207,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($diagnostic['status_code'] ?? null) !== null) {
|
if (($diagnostic['status_code'] ?? null) !== null) {
|
||||||
return array_merge($diagnostic, [
|
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'unexpected_response',
|
'status' => 'unexpected_response',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4219,7 +4215,7 @@ BASH;
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($diagnostic, [
|
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'unreachable',
|
'status' => 'unreachable',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4242,7 +4238,7 @@ BASH;
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
|
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
|
||||||
return array_merge($legacy, [
|
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'status' => 'validated_legacy',
|
'status' => 'validated_legacy',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4251,7 +4247,7 @@ BASH;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($legacy['status_code'] ?? null) === 403) {
|
if (($legacy['status_code'] ?? null) === 403) {
|
||||||
return array_merge($legacy, [
|
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => 'secret_rejected',
|
'status' => 'secret_rejected',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4259,7 +4255,7 @@ BASH;
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($legacy, [
|
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
|
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
|
||||||
'url' => $baseUrl['url'],
|
'url' => $baseUrl['url'],
|
||||||
@@ -4267,6 +4263,17 @@ BASH;
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $probe
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function redactBrokerDiagnosticProbe(array $probe): array
|
||||||
|
{
|
||||||
|
unset($probe['json']);
|
||||||
|
$probe['body_excerpt'] = null;
|
||||||
|
return $probe;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{url:?string,error:?string} $baseUrl
|
* @param array{url:?string,error:?string} $baseUrl
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
@@ -4345,7 +4352,7 @@ BASH;
|
|||||||
'elapsed_ms' => $elapsedMs,
|
'elapsed_ms' => $elapsedMs,
|
||||||
'error' => null,
|
'error' => null,
|
||||||
'json' => is_array($decoded) ? $decoded : null,
|
'json' => is_array($decoded) ? $decoded : null,
|
||||||
'body_excerpt' => self::trimInstallSessionText($body, 512),
|
'body_excerpt' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class edgegateway_c
|
|||||||
edgegateway_broker_url_c::class,
|
edgegateway_broker_url_c::class,
|
||||||
edgegateway_public_broker_url_c::class,
|
edgegateway_public_broker_url_c::class,
|
||||||
edgegateway_broker_auth_mode_c::class,
|
edgegateway_broker_auth_mode_c::class,
|
||||||
edgegateway_broker_shared_secret_c::class,
|
|
||||||
]);
|
]);
|
||||||
$this->enabled = new edgegateway_enabled_c();
|
$this->enabled = new edgegateway_enabled_c();
|
||||||
$this->default_release_channel = new edgegateway_default_release_channel_c();
|
$this->default_release_channel = new edgegateway_default_release_channel_c();
|
||||||
|
|||||||
@@ -39,7 +39,15 @@ class edgeGatewayConfigRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
|
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
|
||||||
$response->success((new edgegateway())->config->getConfigRequest());
|
$config = (new edgegateway())->config->getConfigRequest();
|
||||||
|
foreach ($config as &$entry) {
|
||||||
|
if (($entry['variable'] ?? null) === 'broker_shared_secret') {
|
||||||
|
$entry['value'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($entry);
|
||||||
|
|
||||||
|
$response->success($config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handlePostConfig(): void
|
private function handlePostConfig(): void
|
||||||
@@ -71,7 +79,12 @@ class edgeGatewayConfigRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
|
$diagnosticOptions = array_intersect_key($payload, array_flip([
|
||||||
|
'target',
|
||||||
|
'broker_auth_mode',
|
||||||
|
'broker_shared_secret',
|
||||||
|
]));
|
||||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config');
|
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config');
|
||||||
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload));
|
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($diagnosticOptions));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ class limble_request implements limble_request_i
|
|||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
// Set options to return the response and handle SSL
|
// Set options to return the response and handle SSL
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||||
// Execute the request
|
// Execute the request
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
// Check for errors
|
// Check for errors
|
||||||
@@ -44,11 +44,7 @@ class limble_request implements limble_request_i
|
|||||||
// Check if the response is successful
|
// Check if the response is successful
|
||||||
if ($httpCode < 200 || $httpCode >= 300) {
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||||||
$slack = new \classes\slack();
|
$slack = new \classes\slack();
|
||||||
echo 'Attempting credentials: ' . $url . ' with method: ' . $method . ' and data: ' . json_encode($data) . "\n";
|
$slack->send_message('Limble Request Failed with status code: ' . $httpCode, 'Limble Request Error');
|
||||||
echo 'Response: ' . $response . "\n";
|
|
||||||
echo 'HTTP Code: ' . $httpCode . "\n";
|
|
||||||
echo 'Headers: ' . json_encode($headers) . "\n";
|
|
||||||
$slack->send_message('Limble Request Failed: ' . $response, 'Limble Request Error');
|
|
||||||
throw new \Exception('Request failed with status code ' . $httpCode);
|
throw new \Exception('Request failed with status code ' . $httpCode);
|
||||||
}
|
}
|
||||||
// Check if the response is valid JSON
|
// Check if the response is valid JSON
|
||||||
@@ -68,4 +64,4 @@ class limble_request implements limble_request_i
|
|||||||
// Generate the Basic Auth header using the client ID and secret
|
// Generate the Basic Auth header using the client ID and secret
|
||||||
return 'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret);
|
return 'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
|
|||||||
{
|
{
|
||||||
public ?string $license_plate = null;
|
public ?string $license_plate = null;
|
||||||
public ?int $customer_number = null;
|
public ?int $customer_number = null;
|
||||||
|
public ?int $subuser_id = null;
|
||||||
public bool $defer_relay_side_effects = false;
|
public bool $defer_relay_side_effects = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setSubuserId(?int $subuser_id): self
|
||||||
|
{
|
||||||
|
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||||
{
|
{
|
||||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||||
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
|
|||||||
if (array_key_exists('customer_number', $params)) {
|
if (array_key_exists('customer_number', $params)) {
|
||||||
$this->setCustomerNumber($params['customer_number']);
|
$this->setCustomerNumber($params['customer_number']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('subuser_id', $params)) {
|
||||||
|
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||||
|
}
|
||||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||||
$this->setDeferRelaySideEffects(filter_var(
|
$this->setDeferRelaySideEffects(filter_var(
|
||||||
$params['defer_relay_side_effects'],
|
$params['defer_relay_side_effects'],
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class selfserve_studio_action_runner
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$conditionId = $action['condition_id'];
|
$conditionId = $action['condition_id'];
|
||||||
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$actions[] = $action;
|
$actions[] = $action;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -273,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
|
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
|
||||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Best effort only; session completion flow must continue.
|
// Best effort only; session completion flow must continue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
$status = $lane->getRelayStatus($relay);
|
|
||||||
if ((bool)($status['on'] ?? false) !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// If relay status can't be read, still attempt turn-off as best effort.
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$lane->setRelayStatusHard($relay, false);
|
$lane->setRelayStatusHard($relay, false);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -723,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
|
|
||||||
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
|
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
|
||||||
{
|
{
|
||||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$laneId = (int)$snapshot['lane']['id'];
|
$laneId = (int)$snapshot['lane']['id'];
|
||||||
$lane = (new selfserve())->lane($laneId);
|
$lane = (new selfserve())->lane($laneId);
|
||||||
$this->enableCleanerRelayForStartedWash($lane);
|
$this->enableCleanerRelayForStartedWash($lane);
|
||||||
|
|
||||||
|
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$session->markRelayEnabled();
|
$session->markRelayEnabled();
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
@@ -841,6 +832,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
||||||
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
||||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
||||||
|
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
|
||||||
|
$decisions = $this->buildDebugDecisions(
|
||||||
|
$questions,
|
||||||
|
$conditions,
|
||||||
|
$rules,
|
||||||
|
$tasks,
|
||||||
|
$actions,
|
||||||
|
(array)($hardware['signal_timeline'] ?? []),
|
||||||
|
$dynamicImageButtons
|
||||||
|
);
|
||||||
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
||||||
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
||||||
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
||||||
@@ -871,6 +872,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'rules' => $rules,
|
'rules' => $rules,
|
||||||
'tasks' => $tasks,
|
'tasks' => $tasks,
|
||||||
'actions' => $actions,
|
'actions' => $actions,
|
||||||
|
'dynamic_image_buttons' => $dynamicImageButtons,
|
||||||
|
'decisions' => $decisions,
|
||||||
'hardware' => $hardware,
|
'hardware' => $hardware,
|
||||||
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||||
'graph_annotations' => $annotations,
|
'graph_annotations' => $annotations,
|
||||||
@@ -981,6 +984,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
|
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
|
||||||
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
|
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
|
||||||
: [];
|
: [];
|
||||||
|
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
|
||||||
|
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
|
||||||
|
: [];
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
foreach ($questions as $question) {
|
foreach ($questions as $question) {
|
||||||
@@ -992,20 +998,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$visible = isset($visibleIds[$questionId]);
|
$visible = isset($visibleIds[$questionId]);
|
||||||
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
|
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
|
||||||
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
|
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
|
||||||
|
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
|
||||||
|
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
|
||||||
|
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
|
||||||
|
$causes = [];
|
||||||
|
if ($gateId !== null) {
|
||||||
|
$causes[] = $this->debugCause(
|
||||||
|
'condition',
|
||||||
|
$gateId,
|
||||||
|
$conditionLabel,
|
||||||
|
true,
|
||||||
|
($conditionResults[$gateId] ?? null),
|
||||||
|
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($visible && $answer === null) {
|
||||||
|
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
|
||||||
|
}
|
||||||
|
$reason = $visible
|
||||||
|
? ($answer === null
|
||||||
|
? 'Question ' . $label . ' is visible but has no answer.'
|
||||||
|
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
|
||||||
|
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'kind' => 'question',
|
||||||
'id' => $questionId,
|
'id' => $questionId,
|
||||||
'node_id' => 'question:' . $questionId,
|
'node_id' => 'question:' . $questionId,
|
||||||
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
|
'node_ids' => ['question:' . $questionId],
|
||||||
|
'label' => $label,
|
||||||
'visible' => $visible,
|
'visible' => $visible,
|
||||||
'state' => $state,
|
'state' => $state,
|
||||||
'answer' => $answer,
|
'answer' => $answer,
|
||||||
'answer_source' => $sources[$questionId] ?? 'missing',
|
'answer_source' => $sources[$questionId] ?? 'missing',
|
||||||
'condition_id' => $gateId,
|
'condition_id' => $gateId,
|
||||||
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
|
'condition' => $conditionLabel,
|
||||||
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
|
'gate_satisfied' => $gateSatisfied,
|
||||||
'reason' => $visible
|
'reason' => $reason,
|
||||||
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
|
'causes' => $causes,
|
||||||
: 'Visibility condition did not pass.',
|
|
||||||
'order_priority' => (int)($question['order_priority'] ?? 0),
|
'order_priority' => (int)($question['order_priority'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1036,19 +1065,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
|
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
|
||||||
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
|
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
|
||||||
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
|
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
|
||||||
|
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
|
||||||
|
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
|
||||||
|
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
|
||||||
|
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
|
||||||
|
$reason = $invalidReference
|
||||||
|
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
|
||||||
|
: ($satisfied
|
||||||
|
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
|
||||||
|
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'kind' => 'rule',
|
||||||
'id' => $ruleId,
|
'id' => $ruleId,
|
||||||
'node_id' => 'rule:' . $ruleId,
|
'node_id' => 'rule:' . $ruleId,
|
||||||
|
'node_ids' => ['rule:' . $ruleId],
|
||||||
'condition_id' => (int)($rule['condition_id'] ?? 0),
|
'condition_id' => (int)($rule['condition_id'] ?? 0),
|
||||||
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
|
'label' => $label,
|
||||||
'type' => (string)($rule['type'] ?? ''),
|
'type' => (string)($rule['type'] ?? ''),
|
||||||
'object_type' => $objectType,
|
'object_type' => $objectType,
|
||||||
'object_id' => $objectId,
|
'object_id' => $objectId,
|
||||||
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
|
'object_label' => $objectLabel,
|
||||||
'actual_value' => $actual,
|
'actual_value' => $actual,
|
||||||
'satisfied' => $satisfied,
|
'satisfied' => $satisfied,
|
||||||
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
|
'invalid_reference' => $invalidReference,
|
||||||
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
|
'reason' => $reason,
|
||||||
|
'causes' => [
|
||||||
|
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,10 +1125,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
||||||
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
||||||
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
||||||
|
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
|
||||||
|
$causes = [];
|
||||||
|
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
|
||||||
|
if ($failedExpressionCause !== null) {
|
||||||
|
$causes[] = $failedExpressionCause;
|
||||||
|
} elseif (!$result) {
|
||||||
|
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
|
||||||
|
if (($rule['satisfied'] ?? false) !== true) {
|
||||||
|
foreach ((array)($rule['causes'] ?? []) as $cause) {
|
||||||
|
if (is_array($cause)) {
|
||||||
|
$causes[] = $cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'kind' => 'condition',
|
||||||
'id' => $conditionId,
|
'id' => $conditionId,
|
||||||
'node_id' => 'condition:' . $conditionId,
|
'node_id' => 'condition:' . $conditionId,
|
||||||
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
|
'node_ids' => ['condition:' . $conditionId],
|
||||||
|
'label' => $label,
|
||||||
'result' => $result,
|
'result' => $result,
|
||||||
'state' => $result ? 'passed' : 'failed',
|
'state' => $result ? 'passed' : 'failed',
|
||||||
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
||||||
@@ -1098,6 +1160,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'reason' => in_array($conditionId, $cycleIds, true)
|
'reason' => in_array($conditionId, $cycleIds, true)
|
||||||
? 'Condition dependency cycle detected.'
|
? 'Condition dependency cycle detected.'
|
||||||
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
||||||
|
'causes' => $causes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1193,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
|
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
|
||||||
|
? (array)$snapshot['evaluation_trace']['condition_results']
|
||||||
|
: [];
|
||||||
|
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
|
||||||
|
? (array)$snapshot['debug_candidates']['visible_answers']
|
||||||
|
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
|
||||||
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
@@ -1149,25 +1218,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$active = isset($activeIds[$taskId]);
|
$active = isset($activeIds[$taskId]);
|
||||||
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
|
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
|
||||||
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
|
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
|
||||||
|
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
|
||||||
|
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
|
||||||
|
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
|
||||||
|
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
|
||||||
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
|
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'kind' => 'task',
|
||||||
'id' => $taskId,
|
'id' => $taskId,
|
||||||
'node_id' => 'task:' . $taskId,
|
'node_id' => 'task:' . $taskId,
|
||||||
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
|
'node_ids' => ['task:' . $taskId],
|
||||||
|
'label' => $label,
|
||||||
'description' => (string)($task['description'] ?? ''),
|
'description' => (string)($task['description'] ?? ''),
|
||||||
'active' => $active,
|
'active' => $active,
|
||||||
'state' => $active ? 'active' : 'blocked',
|
'state' => $active ? 'active' : 'blocked',
|
||||||
'gate_type' => $gateType,
|
'gate_type' => $gateType,
|
||||||
'gate_ref_id' => $gateRefId,
|
'gate_ref_id' => $gateRefId,
|
||||||
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
'gate_ref_label' => $gateRefLabel,
|
||||||
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
'gate_satisfied' => $gateSatisfied,
|
||||||
'services' => $services,
|
'services' => $services,
|
||||||
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||||
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||||
'attachments' => $taskAttachments,
|
'attachments' => $taskAttachments,
|
||||||
'relay_bindings' => $bindings,
|
'relay_bindings' => $bindings,
|
||||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||||
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
'reason' => $gateDecision['reason'],
|
||||||
|
'causes' => $gateDecision['causes'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,42 +1288,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
||||||
$scopeMatches = true;
|
$scopeMatches = true;
|
||||||
$scopeReason = null;
|
$scopeReason = null;
|
||||||
|
$scopeCause = null;
|
||||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||||
$scopeMatches = false;
|
$scopeMatches = false;
|
||||||
$scopeReason = 'Action department scope does not match the simulated lane.';
|
$scopeReason = 'Action department scope does not match the simulated lane.';
|
||||||
|
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
|
||||||
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||||
$scopeMatches = false;
|
$scopeMatches = false;
|
||||||
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
||||||
|
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
|
||||||
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
||||||
$scopeMatches = false;
|
$scopeMatches = false;
|
||||||
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
||||||
|
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
|
||||||
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
||||||
$scopeMatches = false;
|
$scopeMatches = false;
|
||||||
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
||||||
|
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||||
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
||||||
$enabled = (bool)($action['enabled'] ?? true);
|
$enabled = (bool)($action['enabled'] ?? true);
|
||||||
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
||||||
|
$label = (string)$action['name'];
|
||||||
|
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
|
||||||
|
$causes = [];
|
||||||
$reason = 'Action would run for this simulator event.';
|
$reason = 'Action would run for this simulator event.';
|
||||||
if (!$enabled) {
|
if (!$enabled) {
|
||||||
$reason = 'Action is disabled.';
|
$reason = 'Action is disabled.';
|
||||||
|
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
|
||||||
} elseif (!$modeMatches) {
|
} elseif (!$modeMatches) {
|
||||||
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
||||||
|
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
|
||||||
} elseif (!$scopeMatches) {
|
} elseif (!$scopeMatches) {
|
||||||
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
||||||
|
if ($scopeCause !== null) {
|
||||||
|
$causes[] = $scopeCause;
|
||||||
|
}
|
||||||
} elseif (!$conditionSatisfied) {
|
} elseif (!$conditionSatisfied) {
|
||||||
$reason = 'Action condition gate did not pass.';
|
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
|
||||||
|
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'kind' => 'action',
|
||||||
'id' => $actionId,
|
'id' => $actionId,
|
||||||
'node_id' => 'action:' . $actionId,
|
'node_id' => 'action:' . $actionId,
|
||||||
'label' => (string)$action['name'],
|
'node_ids' => ['action:' . $actionId],
|
||||||
|
'label' => $label,
|
||||||
'active' => $active,
|
'active' => $active,
|
||||||
'state' => $active ? 'active' : 'skipped',
|
'state' => $active ? 'active' : 'skipped',
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
|
'causes' => $causes,
|
||||||
'event' => (string)$action['event'],
|
'event' => (string)$action['event'],
|
||||||
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
||||||
'wash_mode' => (string)$action['wash_mode'],
|
'wash_mode' => (string)$action['wash_mode'],
|
||||||
@@ -1257,7 +1350,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
||||||
'relay_state' => $action['relay_state'],
|
'relay_state' => $action['relay_state'],
|
||||||
'condition_id' => $conditionId,
|
'condition_id' => $conditionId,
|
||||||
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
|
'condition' => $conditionLabel,
|
||||||
'condition_satisfied' => $conditionSatisfied,
|
'condition_satisfied' => $conditionSatisfied,
|
||||||
'scope' => [
|
'scope' => [
|
||||||
'department' => (int)$action['department'],
|
'department' => (int)$action['department'],
|
||||||
@@ -1758,7 +1851,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
string $predictedStatus,
|
string $predictedStatus,
|
||||||
?string $reason
|
?string $reason
|
||||||
): array {
|
): array {
|
||||||
|
$id = 'signal:' . $sequence;
|
||||||
|
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
|
||||||
|
$targetBinding = $binding['node_id'] ?? null;
|
||||||
|
$nodeIds = [$id];
|
||||||
|
if (isset($payload['action_id'])) {
|
||||||
|
$nodeIds[] = 'action:' . (int)$payload['action_id'];
|
||||||
|
}
|
||||||
|
if (is_string($targetBinding) && $targetBinding !== '') {
|
||||||
|
$nodeIds[] = $targetBinding;
|
||||||
|
}
|
||||||
|
if ($relayId !== null && trim($relayId) !== '') {
|
||||||
|
$nodeIds[] = 'relay:' . $relayId;
|
||||||
|
}
|
||||||
|
$causes = $reason === null ? [] : [
|
||||||
|
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'kind' => 'signal',
|
||||||
|
'id' => $id,
|
||||||
|
'node_id' => $id,
|
||||||
|
'node_ids' => array_values(array_unique($nodeIds)),
|
||||||
|
'label' => $label,
|
||||||
|
'state' => $predictedStatus,
|
||||||
'sequence' => $sequence,
|
'sequence' => $sequence,
|
||||||
'runtime_stage' => $runtimeStage,
|
'runtime_stage' => $runtimeStage,
|
||||||
'signal_type' => $signalType,
|
'signal_type' => $signalType,
|
||||||
@@ -1779,6 +1895,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'predicted_status' => $predictedStatus,
|
'predicted_status' => $predictedStatus,
|
||||||
'skip_block_reason' => $reason,
|
'skip_block_reason' => $reason,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
|
'causes' => $causes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2085,6 +2202,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function debugRuleExpectedValue(string $type): mixed
|
||||||
|
{
|
||||||
|
return match (strtoupper(trim($type))) {
|
||||||
|
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
|
||||||
|
'IS_FALSE' => false,
|
||||||
|
'IS_SET' => 'set',
|
||||||
|
'IS_TRUE_OR_NOT_SET' => 'true or missing',
|
||||||
|
'IS_FALSE_OR_NOT_SET' => 'false or missing',
|
||||||
|
default => strtolower(str_replace('_', ' ', trim($type))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function debugExpectedLabel(mixed $expected): string
|
||||||
|
{
|
||||||
|
return $this->debugValueLabel($expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function debugValueLabel(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === true) {
|
||||||
|
return 'true';
|
||||||
|
}
|
||||||
|
if ($value === false) {
|
||||||
|
return 'false';
|
||||||
|
}
|
||||||
|
if ($value === null) {
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
if (is_array($value)) {
|
||||||
|
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
return $encoded === false ? 'array' : $encoded;
|
||||||
|
}
|
||||||
|
if ($value instanceof \Stringable) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
return trim((string)$value) === '' ? 'empty' : (string)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'kind' => $kind,
|
||||||
|
'id' => $id,
|
||||||
|
'label' => $label,
|
||||||
|
'expected' => $expected,
|
||||||
|
'actual' => $actual,
|
||||||
|
'expected_label' => $this->debugExpectedLabel($expected),
|
||||||
|
'actual_label' => $this->debugValueLabel($actual),
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function debugExpressionTraceReason(mixed $trace): ?string
|
||||||
|
{
|
||||||
|
if (!is_array($trace)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
|
||||||
|
$failed = $this->firstFailedPredicateTrace($expression);
|
||||||
|
if ($failed !== null) {
|
||||||
|
return (string)($failed['reason'] ?? 'Predicate did not pass.');
|
||||||
|
}
|
||||||
|
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $trace
|
||||||
|
* @param array<string,mixed> $lookups
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
|
||||||
|
{
|
||||||
|
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
|
||||||
|
$failed = $this->firstFailedPredicateTrace($expression);
|
||||||
|
if ($failed === null) {
|
||||||
|
if (($expression['result'] ?? true) === false) {
|
||||||
|
return $this->debugCause(
|
||||||
|
'expression',
|
||||||
|
$trace['condition_id'] ?? null,
|
||||||
|
'Condition expression',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
|
||||||
|
$subjectId = (int)($failed['subject_id'] ?? 0);
|
||||||
|
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||||
|
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||||
|
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
|
||||||
|
$actual = $failed['actual_value'] ?? null;
|
||||||
|
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
|
||||||
|
|
||||||
|
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,bool|null> $conditionResults
|
||||||
|
* @param array<int,bool|null> $visibleAnswers
|
||||||
|
* @return array{reason:string,causes:array<int,array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
|
||||||
|
{
|
||||||
|
$gateType = strtoupper(trim($gateType));
|
||||||
|
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
|
||||||
|
if (!$active && $gateSatisfied) {
|
||||||
|
return [
|
||||||
|
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
|
||||||
|
'causes' => [
|
||||||
|
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'reason' => $active
|
||||||
|
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
|
||||||
|
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
|
||||||
|
'causes' => $active ? [] : [
|
||||||
|
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
|
||||||
|
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
|
||||||
|
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
|
||||||
|
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
|
||||||
|
if (!$active && $gateSatisfied) {
|
||||||
|
return [
|
||||||
|
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
|
||||||
|
'causes' => [
|
||||||
|
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
|
||||||
|
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reason' => $reason,
|
||||||
|
'causes' => $active ? [] : [
|
||||||
|
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $tasks
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
protected function buildDebugDynamicImageButtons(array $tasks): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$taskId = (int)($task['id'] ?? 0);
|
||||||
|
if ($taskId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
|
||||||
|
$active = (bool)($task['active'] ?? false);
|
||||||
|
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
|
||||||
|
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
|
||||||
|
$id = $taskId . ':' . (int)$index;
|
||||||
|
$nodeId = 'dynamic_image_button:' . $id;
|
||||||
|
$reason = $active
|
||||||
|
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
|
||||||
|
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
|
||||||
|
$items[] = [
|
||||||
|
'kind' => 'dynamic_image_button',
|
||||||
|
'id' => $id,
|
||||||
|
'node_id' => $nodeId,
|
||||||
|
'node_ids' => ['task:' . $taskId, $nodeId],
|
||||||
|
'label' => $buttonLabel,
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'task_label' => $taskLabel,
|
||||||
|
'button_index' => (int)$index,
|
||||||
|
'button' => $button,
|
||||||
|
'state' => $active ? 'active' : 'hidden',
|
||||||
|
'reason' => $reason,
|
||||||
|
'causes' => $active ? [] : [
|
||||||
|
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function taskUsesProgramPicker(array $task): bool
|
||||||
|
{
|
||||||
|
if (in_array(
|
||||||
|
'PROGRAM_PICKER',
|
||||||
|
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isProgramNumberButton(mixed $button): bool
|
||||||
|
{
|
||||||
|
return is_int($button) && $button >= 0 && $button <= 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function dynamicImageButtonSequenceForTask(array $task): array
|
||||||
|
{
|
||||||
|
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
|
||||||
|
if (!$this->taskUsesProgramPicker($task)) {
|
||||||
|
return $buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sequence = ['program_picker'];
|
||||||
|
foreach ($buttons as $button) {
|
||||||
|
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sequence[] = $button;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeButtonList($sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $questions
|
||||||
|
* @param array<int,array<string,mixed>> $conditions
|
||||||
|
* @param array<int,array<string,mixed>> $rules
|
||||||
|
* @param array<int,array<string,mixed>> $tasks
|
||||||
|
* @param array<int,array<string,mixed>> $actions
|
||||||
|
* @param array<int,array<string,mixed>> $signals
|
||||||
|
* @param array<int,array<string,mixed>> $dynamicImageButtons
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
|
||||||
|
{
|
||||||
|
$decisions = [];
|
||||||
|
foreach ([
|
||||||
|
'question' => $questions,
|
||||||
|
'condition' => $conditions,
|
||||||
|
'rule' => $rules,
|
||||||
|
'task' => $tasks,
|
||||||
|
'action' => $actions,
|
||||||
|
'signal' => $signals,
|
||||||
|
'dynamic_image_button' => $dynamicImageButtons,
|
||||||
|
] as $kind => $items) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$decisions[] = $this->debugDecisionFromItem($kind, $item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $item
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function debugDecisionFromItem(string $kind, array $item): array
|
||||||
|
{
|
||||||
|
$nodeIds = [];
|
||||||
|
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
|
||||||
|
if (is_string($nodeId) && $nodeId !== '') {
|
||||||
|
$nodeIds[] = $nodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
|
||||||
|
$nodeIds[] = $item['node_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = (string)($item['state'] ?? '');
|
||||||
|
if ($state === '') {
|
||||||
|
$state = match ($kind) {
|
||||||
|
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
|
||||||
|
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
|
||||||
|
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
|
||||||
|
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
|
||||||
|
default => 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$causes = [];
|
||||||
|
foreach ((array)($item['causes'] ?? []) as $cause) {
|
||||||
|
if (is_array($cause)) {
|
||||||
|
$causes[] = $cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'kind' => (string)($item['kind'] ?? $kind),
|
||||||
|
'id' => $item['id'] ?? ($item['node_id'] ?? null),
|
||||||
|
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
|
||||||
|
'state' => $state,
|
||||||
|
'reason' => (string)($item['reason'] ?? ''),
|
||||||
|
'node_ids' => array_values(array_unique($nodeIds)),
|
||||||
|
'causes' => $causes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function debugDynamicImageButtonLabel(mixed $button): string
|
||||||
|
{
|
||||||
|
if (is_array($button)) {
|
||||||
|
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
|
||||||
|
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
|
||||||
|
return (string)$button[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
return $encoded === false ? 'Button' : $encoded;
|
||||||
|
}
|
||||||
|
$label = trim((string)$button);
|
||||||
|
if (strtolower($label) === 'program_picker') {
|
||||||
|
return 'Program picker';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label === '' ? 'Button' : $label;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int,array<string,mixed>> $conditions
|
* @param array<int,array<string,mixed>> $conditions
|
||||||
* @param array<int,array<string,mixed>> $rules
|
* @param array<int,array<string,mixed>> $rules
|
||||||
@@ -2213,6 +2657,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $failed;
|
return $failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach (['when', 'then', 'default'] as $field) {
|
||||||
|
if (!is_array($trace[$field] ?? null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
|
||||||
|
if ($failed !== null) {
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['branches', 'cases'] as $field) {
|
||||||
|
foreach ((array)($trace[$field] ?? []) as $child) {
|
||||||
|
if (!is_array($child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$failed = $this->firstFailedPredicateTrace((array)$child);
|
||||||
|
if ($failed !== null) {
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
|
|||||||
enum selfserve_lane_services
|
enum selfserve_lane_services
|
||||||
{
|
{
|
||||||
case MACHINE; // Relay that controls the machine power
|
case MACHINE; // Relay that controls the machine power
|
||||||
|
case PROGRAM_PICKER; // Relay that controls the machine program picker
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Route parameter name is `condition_id`. That value is used as the task gate id.
|
- Route parameter name is `condition_id`. That value is used as the task gate id.
|
||||||
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
|
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
|
||||||
- If an active task does not expose `MACHINE`, the relay will not be enabled.
|
- If an active task does not expose `MACHINE`, the relay will not be enabled.
|
||||||
|
|
||||||
Typical failures:
|
Typical failures:
|
||||||
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
|
|||||||
| Method | Required params | Permissions | Notes |
|
| Method | Required params | Permissions | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
|
||||||
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
||||||
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
||||||
|
|
||||||
@@ -382,6 +382,12 @@ STOP flow details:
|
|||||||
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
||||||
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
||||||
|
|
||||||
|
Customer start-wash release checklist:
|
||||||
|
|
||||||
|
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
|
||||||
|
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
|
||||||
|
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
|
||||||
|
|
||||||
Typical failures:
|
Typical failures:
|
||||||
|
|
||||||
- `400` invalid parameters
|
- `400` invalid parameters
|
||||||
@@ -480,7 +486,7 @@ Important enums:
|
|||||||
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
|
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
|
||||||
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
|
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
|
||||||
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
|
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
|
||||||
- `selfserve_lane_services`: currently only `MACHINE`
|
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
|
||||||
|
|
||||||
### Machine Type And Wash Session Objects
|
### Machine Type And Wash Session Objects
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
|
|||||||
{
|
{
|
||||||
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
|
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
|
||||||
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
|
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
|
||||||
|
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
|
||||||
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
|
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
|
||||||
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
|
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
|
||||||
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
|
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
|
||||||
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
|
|||||||
redis->delete($this->getLaneCacheKey($laneId, $property));
|
redis->delete($this->getLaneCacheKey($laneId, $property));
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,49 @@ use objects\department_variables_o;
|
|||||||
|
|
||||||
trait selfserve_lane_command_t
|
trait selfserve_lane_command_t
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire an atomic per-lane START lock before performing physical side effects.
|
||||||
|
*/
|
||||||
|
protected function acquireLaneStartCommandLock(): string
|
||||||
|
{
|
||||||
|
if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) {
|
||||||
|
throw new \RuntimeException('Cannot start lane: START lock is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$lock_key = $this->getLaneStartCommandLockKey();
|
||||||
|
if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) {
|
||||||
|
throw new \RuntimeException("Cannot start lane: Lane is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function releaseLaneStartCommandLock(string $token): void
|
||||||
|
{
|
||||||
|
if (!defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock_key = $this->getLaneStartCommandLockKey();
|
||||||
|
try {
|
||||||
|
if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method_exists(redis, 'delete')) {
|
||||||
|
redis->delete($lock_key);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// The lock has a short TTL, so release failures must not mask START results.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLaneStartCommandLockKey(): string
|
||||||
|
{
|
||||||
|
return 'selfserve_lane_start_command_lock_' . (int)$this->id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the lane and its department have self-serve enabled.
|
* Determine if the lane and its department have self-serve enabled.
|
||||||
* This method is intentionally protected to allow tests to override
|
* This method is intentionally protected to allow tests to override
|
||||||
@@ -464,6 +507,7 @@ trait selfserve_lane_command_t
|
|||||||
* @param selfserve_lane_command_arguments $arguments The arguments for the command
|
* @param selfserve_lane_command_arguments $arguments The arguments for the command
|
||||||
* @return selfserve_lane|selfserve_lane_command_t
|
* @return selfserve_lane|selfserve_lane_command_t
|
||||||
* @throws Exception If the command cannot be executed
|
* @throws Exception If the command cannot be executed
|
||||||
|
* @throws \Throwable
|
||||||
*/
|
*/
|
||||||
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
|
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
|
||||||
{
|
{
|
||||||
@@ -508,38 +552,48 @@ trait selfserve_lane_command_t
|
|||||||
// Validate customer number
|
// Validate customer number
|
||||||
if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
|
if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
|
||||||
if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
|
if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
|
||||||
$previous_customer_number = $this->getCustomerNumber();
|
$start_lock_token = $this->acquireLaneStartCommandLock();
|
||||||
$previous_license_plate = $this->getLicensePlate();
|
|
||||||
// Set the customer number and license plate
|
|
||||||
$this->setCustomerNumber($customer_number);
|
|
||||||
$this->setLicensePlate($license_plate);
|
|
||||||
try {
|
try {
|
||||||
// Open the entrance port before marking the lane occupied. Gateway timeouts are
|
// Re-check availability after taking the START lock so concurrent requests cannot
|
||||||
// ambiguous because the relay may already have received the pulse.
|
// both pass the preflight check and trigger the physical entrance relay.
|
||||||
$this->openEntrancePortForWashStart();
|
if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
|
||||||
} catch (\Throwable $e) {
|
$previous_customer_number = $this->getCustomerNumber();
|
||||||
$this->setCustomerNumber($previous_customer_number);
|
$previous_license_plate = $this->getLicensePlate();
|
||||||
$this->setLicensePlate($previous_license_plate);
|
$previous_status = $this->getLaneStatus();
|
||||||
$this->setLaneState(selfserve_lane_state::IDLE);
|
// Set the customer number and license plate
|
||||||
throw $e;
|
$this->setCustomerNumber($customer_number);
|
||||||
|
$this->setLicensePlate($license_plate);
|
||||||
|
// Mark the lane occupied before any physical entrance relay side effects.
|
||||||
|
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||||
|
try {
|
||||||
|
// Gateway timeouts are ambiguous because the relay may already have received
|
||||||
|
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
||||||
|
$this->openEntrancePortForWashStart();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->setCustomerNumber($previous_customer_number);
|
||||||
|
$this->setLicensePlate($previous_license_plate);
|
||||||
|
$this->setLaneStatus($previous_status);
|
||||||
|
$this->setLaneState(selfserve_lane_state::IDLE);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
// Set the lane state to IN_WASH
|
||||||
|
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||||
|
// Start the wash timer
|
||||||
|
$this->setWashStartTime(time());
|
||||||
|
$this->runPublishedStudioActions(
|
||||||
|
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||||
|
$this->resolveSelfServeActionWashModeForStart(),
|
||||||
|
[
|
||||||
|
'customer_number' => (int)$customer_number,
|
||||||
|
'reg' => $license_plate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$this->runRelaySideEffectsForWashStart($arguments);
|
||||||
|
// Log the lane start event
|
||||||
|
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||||
|
} finally {
|
||||||
|
$this->releaseLaneStartCommandLock($start_lock_token);
|
||||||
}
|
}
|
||||||
// Set the lane status to OCCUPIED when started
|
|
||||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
|
||||||
// Set the lane state to IN_WASH
|
|
||||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
|
||||||
// Start the wash timer
|
|
||||||
$this->setWashStartTime(time());
|
|
||||||
$this->runPublishedStudioActions(
|
|
||||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
|
||||||
$this->resolveSelfServeActionWashModeForStart(),
|
|
||||||
[
|
|
||||||
'customer_number' => (int)$customer_number,
|
|
||||||
'reg' => $license_plate,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
$this->runRelaySideEffectsForWashStart($arguments);
|
|
||||||
// Log the lane start event
|
|
||||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::STOP:
|
case selfserve_lane_command::STOP:
|
||||||
// Require lane to be occupied before stopping
|
// Require lane to be occupied before stopping
|
||||||
@@ -567,7 +621,7 @@ trait selfserve_lane_command_t
|
|||||||
// Log the lane stop event
|
// Log the lane stop event
|
||||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||||
// Invoice the customer
|
// Invoice the customer
|
||||||
$this->invoice();
|
$this->invoice($arguments);
|
||||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||||
// Finalize any active self-serve wash session for this lane
|
// Finalize any active self-serve wash session for this lane
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
|
|||||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
||||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
|
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
|
||||||
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
||||||
|
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||||
|
require_once WD . '/modules/attachments/helpers/attachment_content.php';
|
||||||
|
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||||
|
require_once WD . '/objects/subusers_o.php';
|
||||||
|
|
||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
|
use classes\economic;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use attachments\helpers\attachment_content;
|
||||||
|
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||||
use modules\selfserve\classes\selfserve_lane;
|
use modules\selfserve\classes\selfserve_lane;
|
||||||
use modules\selfserve\helpers\selfserve_lane_mode;
|
use modules\selfserve\helpers\selfserve_lane_mode;
|
||||||
use modules\selfserve\helpers\selfserve_lane_status;
|
use modules\selfserve\helpers\selfserve_lane_status;
|
||||||
use objects\customer_vehicles_o;
|
use objects\customer_vehicles_o;
|
||||||
use objects\order_items_o;
|
use objects\order_items_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
|
use objects\selfserve_wash_sessions_o;
|
||||||
|
use objects\subusers_o;
|
||||||
|
|
||||||
trait selfserve_lane_invoice_t
|
trait selfserve_lane_invoice_t
|
||||||
{
|
{
|
||||||
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
|
|||||||
* @return bool True on success, false on failure
|
* @return bool True on success, false on failure
|
||||||
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
|
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
|
||||||
*/
|
*/
|
||||||
public function invoice(): bool
|
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
|
||||||
{
|
{
|
||||||
$this->last_invoice_order_id = null;
|
$this->last_invoice_order_id = null;
|
||||||
|
|
||||||
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
|
|||||||
$included_minutes = $this->resolveIncludedMinutesForBilling();
|
$included_minutes = $this->resolveIncludedMinutesForBilling();
|
||||||
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
|
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
|
||||||
|
|
||||||
if ($billable_minutes <= 0) {
|
if ($billable_minutes > 0) {
|
||||||
return true;
|
$order = $this->createInvoiceOrderContext($arguments);
|
||||||
|
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = $this->createInvoiceOrderContext();
|
|
||||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +219,12 @@ trait selfserve_lane_invoice_t
|
|||||||
return max(0, $elapsed_minutes - $included_minutes);
|
return max(0, $elapsed_minutes - $included_minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createInvoiceOrderContext(): orders_o
|
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
|
||||||
{
|
{
|
||||||
|
$billing_customer_number = $this->getCustomerNumber();
|
||||||
|
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
||||||
$order = (new orders_o())->add(
|
$order = (new orders_o())->add(
|
||||||
$this->getCustomerNumber(),
|
$billing_customer_number,
|
||||||
self::INVOICE_SYSTEM_USER_ID,
|
self::INVOICE_SYSTEM_USER_ID,
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
@@ -224,10 +233,101 @@ trait selfserve_lane_invoice_t
|
|||||||
);
|
);
|
||||||
$order->lane->set($this->id);
|
$order->lane->set($this->id);
|
||||||
$this->last_invoice_order_id = (int)$order->id;
|
$this->last_invoice_order_id = (int)$order->id;
|
||||||
|
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function attachSelfServeMetadataToOrder(
|
||||||
|
orders_o $order,
|
||||||
|
int $billing_customer_number,
|
||||||
|
?int $draft_customer_number,
|
||||||
|
?selfserve_lane_command_arguments $arguments = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$order->addAttachment(
|
||||||
|
(new attachment_content())->setOther(
|
||||||
|
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Metadata attachments must not block billing; the order itself is the source of record.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildSelfServeOrderAttachmentPayload(
|
||||||
|
int $billing_customer_number,
|
||||||
|
?int $draft_customer_number,
|
||||||
|
?selfserve_lane_command_arguments $arguments = null
|
||||||
|
): array {
|
||||||
|
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
||||||
|
$subuser_id = $arguments?->subuser_id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||||
|
'source' => 'selfserve',
|
||||||
|
'customer_number' => $billing_customer_number,
|
||||||
|
'draft_customer_number' => $draft_customer_number,
|
||||||
|
'subuser_id' => $subuser_id,
|
||||||
|
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
|
||||||
|
'session_id' => $session?->id,
|
||||||
|
'lane_id' => (int)$this->id,
|
||||||
|
'department_id' => (int)$this->department_lane->department->value(),
|
||||||
|
'license_plate' => (string)$this->getLicensePlate(),
|
||||||
|
'lane_status' => $this->getLaneStatus()->name,
|
||||||
|
'lane_mode' => $this->getLaneMode()->name,
|
||||||
|
'wash_start_time' => (int)$this->getWashStartTime(),
|
||||||
|
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
||||||
|
{
|
||||||
|
$license_plate = trim((string)$this->getLicensePlate());
|
||||||
|
if ($license_plate === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||||
|
(int)$this->id,
|
||||||
|
selfserve::standardize_registration($license_plate),
|
||||||
|
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||||
|
);
|
||||||
|
return $session->exists() ? $session : null;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
|
||||||
|
{
|
||||||
|
if ($subuser_id === null || $subuser_id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$subuser = (new subusers_o())->select($subuser_id);
|
||||||
|
if (!$subuser->exists()) {
|
||||||
|
return [
|
||||||
|
'id' => $subuser_id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$subuser->id,
|
||||||
|
'name' => $subuser->name->value(),
|
||||||
|
'username' => $subuser->username->value(),
|
||||||
|
'email' => $subuser->email->value(),
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [
|
||||||
|
'id' => $subuser_id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
|
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
|
||||||
{
|
{
|
||||||
return (new order_items_o())->addItemToOrder(
|
return (new order_items_o())->addItemToOrder(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ trait selfserve_lane_port_controller_t
|
|||||||
{
|
{
|
||||||
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
||||||
private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1;
|
private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1;
|
||||||
|
private const MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the lane port
|
* Open the lane port
|
||||||
@@ -105,7 +106,7 @@ trait selfserve_lane_port_controller_t
|
|||||||
private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int
|
private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int
|
||||||
{
|
{
|
||||||
if ($toggle_after_seconds !== null && $toggle_after_seconds > 0) {
|
if ($toggle_after_seconds !== null && $toggle_after_seconds > 0) {
|
||||||
return $toggle_after_seconds;
|
return min($toggle_after_seconds, self::MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS;
|
return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS;
|
||||||
|
|||||||
@@ -107,14 +107,16 @@ trait selfserve_lane_relay_controller_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set MACHINE relay status directly.
|
* Set MACHINE relay status using the same guards as manual relay controls.
|
||||||
* @param bool $on true to turn on, false to turn off
|
* @param bool $on true to turn on, false to turn off
|
||||||
* @return bool
|
* @return bool
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function setMachineRelayStatus(bool $on): bool
|
public function setMachineRelayStatus(bool $on): bool
|
||||||
{
|
{
|
||||||
return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on);
|
return $on
|
||||||
|
? $this->turnOnRelay(selfserve_lane_relay::MACHINE)
|
||||||
|
: $this->turnOffRelay(selfserve_lane_relay::MACHINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function setLaneStatusAudit(?array $audit): self
|
||||||
|
{
|
||||||
|
if ($audit === null) {
|
||||||
|
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
|
||||||
|
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
|
||||||
|
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
|
||||||
|
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLaneStatusAudit(): ?array
|
||||||
|
{
|
||||||
|
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||||
|
if (!is_array($audit)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
|
||||||
|
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
|
||||||
|
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
|
||||||
|
? (int)$audit['modified_by_user_id']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'modified_at' => $modified_at !== '' ? $modified_at : null,
|
||||||
|
'modified_by_user_id' => $modified_by_user_id,
|
||||||
|
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ if (!is_numeric($certificate_id) || $certificate_id < 1) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add authentication here
|
// Require a valid static token before serving certificates
|
||||||
|
if (!isset($_GET['secret_token']) || $_GET['secret_token'] !== $WORDPRESS_STATIC_TOKEN) {
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the certificate exists in the /output/certificates folder
|
// Check if the certificate exists in the /output/certificates folder
|
||||||
if (!file_exists("../output/certificates/wash_certificate_" . $certificate_id . ".pdf")) {
|
if (!file_exists("../output/certificates/wash_certificate_" . $certificate_id . ".pdf")) {
|
||||||
@@ -34,4 +38,4 @@ header('Content-Disposition: attachment; filename="wash certificate ' . $certifi
|
|||||||
|
|
||||||
// Output the certificate
|
// Output the certificate
|
||||||
readfile("../output/certificates/wash_certificate_" . $certificate_id . ".pdf");
|
readfile("../output/certificates/wash_certificate_" . $certificate_id . ".pdf");
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ if ($wash_certificate_store->washCertificateExists($_GET['bookingId'])) {
|
|||||||
$success = $wash_certificate_store->uploadFile("wash_certificate_" . $_GET['bookingId'] . ".pdf", dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
|
$success = $wash_certificate_store->uploadFile("wash_certificate_" . $_GET['bookingId'] . ".pdf", dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
|
||||||
// If the certificate was uploaded successfully, delete the local copy
|
// If the certificate was uploaded successfully, delete the local copy
|
||||||
if ($success) {
|
if ($success) {
|
||||||
//unlink(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
|
unlink(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
|
||||||
// Return the certificate url
|
// Return the certificate url
|
||||||
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
|
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
|
||||||
exit;
|
exit;
|
||||||
@@ -131,7 +131,7 @@ $generatedCertificatePath = "output/certificates/wash_certificate_" . $_GET['boo
|
|||||||
$wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath);
|
$wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath);
|
||||||
|
|
||||||
// Delete the local copy of the certificate
|
// Delete the local copy of the certificate
|
||||||
//unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
|
unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
|
||||||
|
|
||||||
// Set the status of the booking to completed
|
// Set the status of the booking to completed
|
||||||
$booking = new bookings_o();
|
$booking = new bookings_o();
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ use traits\module_config_variable;
|
|||||||
|
|
||||||
class workfeed_api_url_c
|
class workfeed_api_url_c
|
||||||
{
|
{
|
||||||
use module_config_variable;
|
use module_config_variable {
|
||||||
|
validateVariableValue as private validateModuleConfigVariableValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const TRUSTED_API_HOSTS = [
|
||||||
|
'api.workfeed.io',
|
||||||
|
'europe-west1-production-eu-327a3.cloudfunctions.net',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -20,10 +27,54 @@ class workfeed_api_url_c
|
|||||||
'string',
|
'string',
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
'The base URL for the Workfeed API (see docs.workfeed.io)',
|
'The base URL for the Workfeed API (trusted Workfeed endpoints only; see docs.workfeed.io)',
|
||||||
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api',
|
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api',
|
||||||
false,
|
false,
|
||||||
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api'
|
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function validateVariableValue(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (!$this->validateModuleConfigVariableValue($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isTrustedApiUrl((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isTrustedApiUrl(string $url): bool
|
||||||
|
{
|
||||||
|
$normalizedUrl = self::normalizeApiUrlForValidation($url);
|
||||||
|
if (filter_var($normalizedUrl, FILTER_VALIDATE_URL) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($normalizedUrl);
|
||||||
|
|
||||||
|
if ($parts === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
$host = strtolower((string)($parts['host'] ?? ''));
|
||||||
|
$port = $parts['port'] ?? null;
|
||||||
|
|
||||||
|
return $scheme === 'https'
|
||||||
|
&& in_array($host, self::TRUSTED_API_HOSTS, true)
|
||||||
|
&& ($port === null || $port === 443)
|
||||||
|
&& !isset($parts['user'])
|
||||||
|
&& !isset($parts['pass']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeApiUrlForValidation(string $url): string
|
||||||
|
{
|
||||||
|
$url = trim($url);
|
||||||
|
|
||||||
|
if (preg_match('#^https?://#i', $url)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://' . ltrim($url, '/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ class bookings_o extends db
|
|||||||
$washCertificateStatus = $db->escape_string($washCertificateStatus);
|
$washCertificateStatus = $db->escape_string($washCertificateStatus);
|
||||||
$washCertificateUrl = $db->escape_string($washCertificateUrl);
|
$washCertificateUrl = $db->escape_string($washCertificateUrl);
|
||||||
$status = $db->escape_string($status);
|
$status = $db->escape_string($status);
|
||||||
|
$pickup_bool = (int)$pickup_bool;
|
||||||
// Check if the entry already exists
|
// Check if the entry already exists
|
||||||
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ class department_lanes_o extends db
|
|||||||
|
|
||||||
public function asArray(): array
|
public function asArray(): array
|
||||||
{
|
{
|
||||||
|
$status = (string)$this->getLaneStatus()->name;
|
||||||
|
$machine_status_audit = $this->getMachineStatusAudit();
|
||||||
|
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int)$this->id,
|
'id' => (int)$this->id,
|
||||||
'department' => (int)$this->department->value(),
|
'department' => (int)$this->department->value(),
|
||||||
@@ -143,13 +147,76 @@ class department_lanes_o extends db
|
|||||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||||
// Status of the lane
|
// Status of the lane
|
||||||
'status' => (string)$this->getLaneStatus()->name,
|
'status' => $status,
|
||||||
|
'machine_status_enabled' => self::isOperationalStatusName($status),
|
||||||
|
'machine_status_audit' => $machine_status_audit,
|
||||||
|
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
|
||||||
|
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
|
||||||
|
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
|
||||||
|
'selfserve_configured' => $selfserve_configuration_warnings === [],
|
||||||
|
'dognvask_configured' => $selfserve_configuration_warnings === [],
|
||||||
|
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
|
||||||
// Timestamps
|
// Timestamps
|
||||||
'created_at' => (string)$this->created_at->value(),
|
'created_at' => (string)$this->created_at->value(),
|
||||||
'updated_at' => (string)$this->updated_at->value(),
|
'updated_at' => (string)$this->updated_at->value(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getMachineStatusAudit(): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$lane = (new selfserve())->lane((int)$this->id);
|
||||||
|
if (!method_exists($lane, 'getLaneStatusAudit')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$audit = $lane->getLaneStatusAudit();
|
||||||
|
return is_array($audit) ? $audit : null;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelfServeConfigurationWarnings(): array
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
$required_fields = [
|
||||||
|
'relay_in_id' => 'Indgangsrelæ',
|
||||||
|
'relay_out_id' => 'Udgangsrelæ',
|
||||||
|
'relay_machine_id' => 'Maskinrelæ',
|
||||||
|
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
|
||||||
|
'relay_machine_cleaner_id' => 'Vaskerelæ',
|
||||||
|
'dynamic_image_id' => 'Maskinstatusbillede',
|
||||||
|
'machine_type_id' => 'Maskintype',
|
||||||
|
];
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
foreach ($required_fields as $field => $label) {
|
||||||
|
if ($this->hasConfiguredFieldValue($field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings[] = [
|
||||||
|
'field' => $field,
|
||||||
|
'label' => $label,
|
||||||
|
'message' => $label . ' mangler',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSelfServeConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->getSelfServeConfigurationWarnings() === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isOperationalStatusName(string $status): bool
|
||||||
|
{
|
||||||
|
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
|
||||||
|
}
|
||||||
|
|
||||||
public function isSelfServeEnabled(): bool
|
public function isSelfServeEnabled(): bool
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
@@ -184,6 +251,29 @@ class department_lanes_o extends db
|
|||||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasConfiguredFieldValue(string $field): bool
|
||||||
|
{
|
||||||
|
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->{$field}->value();
|
||||||
|
if ($value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$value = trim($value);
|
||||||
|
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int)$value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool)$value;
|
||||||
|
}
|
||||||
|
|
||||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||||
{
|
{
|
||||||
if ($lane_id <= 0) {
|
if ($lane_id <= 0) {
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
|
|||||||
if (is_string($btn)) {
|
if (is_string($btn)) {
|
||||||
$trimmed = trim($btn);
|
$trimmed = trim($btn);
|
||||||
$specialButton = strtolower($trimmed);
|
$specialButton = strtolower($trimmed);
|
||||||
if ($specialButton === 'reset' || $specialButton === 'start') {
|
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
|
||||||
$ids[] = $specialButton;
|
$ids[] = $specialButton;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ class order_bookings_o extends db
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->requireLinkedOrderMatchesBooking($order);
|
||||||
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
||||||
if ($normalizedSafetySeal !== null) {
|
if ($normalizedSafetySeal !== null) {
|
||||||
$order->setSafetySealValue($normalizedSafetySeal);
|
$order->setSafetySealValue($normalizedSafetySeal);
|
||||||
@@ -414,11 +415,16 @@ class order_bookings_o extends db
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$orderItems = new order_items_o();
|
$orderItems = new order_items_o();
|
||||||
|
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
|
||||||
|
? (string)$item['notes']
|
||||||
|
: ((string)($this->note->value() ?? '') ?: null);
|
||||||
$orderItems->addItemToOrder(
|
$orderItems->addItemToOrder(
|
||||||
(int)$order->id,
|
(int)$order->id,
|
||||||
(int)$item['id'],
|
(int)$item['id'],
|
||||||
(int)$user_id,
|
(int)$user_id,
|
||||||
(int)$item['quantity'],
|
(int)$item['quantity'],
|
||||||
|
null,
|
||||||
|
$itemNotes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,14 +468,34 @@ class order_bookings_o extends db
|
|||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function requireLinkedOrderMatchesBooking(orders_o $order): void
|
||||||
|
{
|
||||||
|
self::requireSelected();
|
||||||
|
|
||||||
|
$bookingCustomerNumber = (int)$this->customer_number->value();
|
||||||
|
$bookingDepartmentId = (int)$this->department->value();
|
||||||
|
$orderCustomerId = (int)$order->customer_id->value();
|
||||||
|
$orderDepartmentId = (int)$order->department_id->value();
|
||||||
|
|
||||||
|
if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) {
|
||||||
|
throw new Exception('Linked order does not match booking customer or department');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
|
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
$order = $this->getOrder();
|
||||||
|
$this->requireLinkedOrderMatchesBooking($order);
|
||||||
|
|
||||||
// Check if the order already has a wash certificate attached
|
// Check if the order already has a wash certificate attached
|
||||||
if ($this->getOrder()->hasWashCertificateAttached()) {
|
if ($order->hasWashCertificateAttached()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Get the operator name
|
// Get the operator name
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ class orders_o extends db
|
|||||||
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
|
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
$plate = $db->escape_string($plate);
|
||||||
|
$entries = max(1, $entries);
|
||||||
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
|
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
return $db->fetch_all($result);
|
return $db->fetch_all($result);
|
||||||
@@ -537,6 +539,7 @@ class orders_o extends db
|
|||||||
{
|
{
|
||||||
global /** @var db $db */
|
global /** @var db $db */
|
||||||
$db;
|
$db;
|
||||||
|
$plate = $db->escape_string($plate);
|
||||||
$sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
|
$sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
$orders = $db->fetch_all($result);
|
$orders = $db->fetch_all($result);
|
||||||
@@ -590,8 +593,14 @@ class orders_o extends db
|
|||||||
public function get_vehicle_order_history(string $plate): array
|
public function get_vehicle_order_history(string $plate): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
|
$stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5");
|
||||||
$result = $db->query($sql);
|
if (!$stmt) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$stmt->bind_param('sss', $plate, $plate, $plate);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
return $db->fetch_all($result);
|
return $db->fetch_all($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2155,7 +2164,6 @@ class orders_o extends db
|
|||||||
}
|
}
|
||||||
return $orders;
|
return $orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class products_o extends db
|
|||||||
{
|
{
|
||||||
use db_object_t;
|
use db_object_t;
|
||||||
|
|
||||||
|
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
|
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the product
|
* The name of the product
|
||||||
* @var object_property
|
* @var object_property
|
||||||
@@ -214,7 +217,7 @@ class products_o extends db
|
|||||||
'piktogram' => $this->piktogram->value(),
|
'piktogram' => $this->piktogram->value(),
|
||||||
'economic_product_id' => $this->economic_product_id->value(),
|
'economic_product_id' => $this->economic_product_id->value(),
|
||||||
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
|
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
|
||||||
'requires_note' => (bool)$this->requires_note->value(),
|
'requires_note' => $this->requiresOrderItemNote(),
|
||||||
'is_wash' => (bool)$this->is_wash->value(),
|
'is_wash' => (bool)$this->is_wash->value(),
|
||||||
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
||||||
'order_priority' => (int)$this->order_priority->value(),
|
'order_priority' => (int)$this->order_priority->value(),
|
||||||
@@ -224,6 +227,28 @@ class products_o extends db
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function productDataRequiresOrderItemNote(array $product): bool
|
||||||
|
{
|
||||||
|
if ((bool)($product['requires_note'] ?? false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresOrderItemNote(): bool
|
||||||
|
{
|
||||||
|
return self::productDataRequiresOrderItemNote([
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => (string)$this->name->value(),
|
||||||
|
'requires_note' => (bool)$this->requires_note->value(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply department pricing to a list of products
|
* Apply department pricing to a list of products
|
||||||
* @param array $products
|
* @param array $products
|
||||||
|
|||||||
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const defaultPermissions = [
|
const defaultPermissions = [
|
||||||
subusers_permission_node_key::VEHICLES_LIST,
|
'VEHICLES_LIST',
|
||||||
subusers_permission_node_key::SELFSERVE_ADD,
|
'SELFSERVE_ADD',
|
||||||
subusers_permission_node_key::BOOKINGS_LIST,
|
'BOOKINGS_LIST',
|
||||||
subusers_permission_node_key::BOOKINGS_ADD,
|
'BOOKINGS_ADD',
|
||||||
subusers_permission_node_key::BOOKINGS_EDIT,
|
'BOOKINGS_EDIT',
|
||||||
subusers_permission_node_key::BOOKINGS_DELETE,
|
'BOOKINGS_DELETE',
|
||||||
];
|
];
|
||||||
|
|
||||||
private static function normalizePermissionsValue(mixed $raw): array
|
public static function normalizePermissionsValue(mixed $raw): array
|
||||||
{
|
{
|
||||||
if ($raw === null || $raw === '') {
|
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($raw instanceof subusers_permission_node_key) {
|
||||||
|
return [$raw->name];
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($raw)) {
|
if (is_array($raw)) {
|
||||||
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
$permissions = [];
|
||||||
|
$permissionCandidates = array_is_list($raw)
|
||||||
|
? $raw
|
||||||
|
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
|
||||||
|
|
||||||
|
foreach ($permissionCandidates as $permission) {
|
||||||
|
if ($permission instanceof subusers_permission_node_key) {
|
||||||
|
$permission = $permission->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($permission)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permission = strtoupper(trim($permission));
|
||||||
|
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||||
|
$permissions[] = $permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($permissions));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($raw)) {
|
if (is_string($raw)) {
|
||||||
$decoded = json_decode($raw, true);
|
$decoded = json_decode($raw, true);
|
||||||
if (is_array($decoded)) {
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
return self::normalizePermissionsValue($decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permission = strtoupper(trim($raw));
|
||||||
|
if (subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||||
|
return [$permission];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
|
|||||||
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
$permissions = self::normalizePermissionsValue($permissions);
|
||||||
$tmp = $this->add_object([
|
$tmp = $this->add_object([
|
||||||
'billing_customer_number' => (int)$billing_customer_number,
|
'billing_customer_number' => (int)$billing_customer_number,
|
||||||
'subuser' => (int)$subuser,
|
'subuser' => (int)$subuser,
|
||||||
'enabled' => (bool)$enabled,
|
'enabled' => (bool)$enabled,
|
||||||
'note' => !empty($note) ? $db->escape_string($note) : null,
|
'note' => !empty($note) ? $db->escape_string($note) : null,
|
||||||
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
|
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
]);
|
]);
|
||||||
$this->id = (int)$tmp;
|
$this->id = (int)$tmp;
|
||||||
$this->getObjectProperties();
|
$this->getObjectProperties();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class subusers_o extends db
|
|||||||
{
|
{
|
||||||
use db_object_t;
|
use db_object_t;
|
||||||
|
|
||||||
|
public const PASSWORD_MIN_LENGTH = 8;
|
||||||
|
public const PASSWORD_MAX_LENGTH = 255;
|
||||||
|
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
|
||||||
|
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
|
||||||
|
|
||||||
public object_property $username;
|
public object_property $username;
|
||||||
public object_property $password;
|
public object_property $password;
|
||||||
public object_property $name;
|
public object_property $name;
|
||||||
@@ -62,6 +67,23 @@ class subusers_o extends db
|
|||||||
return (bool)$this->two_factor_enabled->value();
|
return (bool)$this->two_factor_enabled->value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function assertValidPassword(string $password): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
strlen($password) < self::PASSWORD_MIN_LENGTH
|
||||||
|
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|
||||||
|
|| !preg_match(self::PASSWORD_PATTERN, $password)
|
||||||
|
) {
|
||||||
|
throw new Exception(
|
||||||
|
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
|
||||||
|
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -103,11 +125,9 @@ class subusers_o extends db
|
|||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
if (!empty($password)) {
|
$passwordWasProvided = !empty($password);
|
||||||
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
|
if ($passwordWasProvided) {
|
||||||
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
|
self::assertValidPassword($password);
|
||||||
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
|
|
||||||
}
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
$password = password_hash($password, PASSWORD_DEFAULT);
|
$password = password_hash($password, PASSWORD_DEFAULT);
|
||||||
}
|
}
|
||||||
@@ -163,6 +183,7 @@ class subusers_o extends db
|
|||||||
public function setPassword(string $password): self
|
public function setPassword(string $password): self
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
self::assertValidPassword($password);
|
||||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -299,9 +320,6 @@ class subusers_o extends db
|
|||||||
$session_token = bin2hex(random_bytes(32));
|
$session_token = bin2hex(random_bytes(32));
|
||||||
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
|
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
|
||||||
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
|
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
|
||||||
// Add the token
|
|
||||||
$tokens_o = new tokens_o();
|
|
||||||
$tokens_o->create($this->id, $session_token, 'AUTH_TOKEN_SUBUSER');
|
|
||||||
return $session_token;
|
return $session_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,22 +343,6 @@ class subusers_o extends db
|
|||||||
$subuser->getObjectProperties();
|
$subuser->getObjectProperties();
|
||||||
return $subuser;
|
return $subuser;
|
||||||
}
|
}
|
||||||
// Fallback: resolve via tokens table if cache is missing/expired
|
|
||||||
try {
|
|
||||||
// tokens_o::getToken() might throw Exception if not found
|
|
||||||
$tok = (new tokens_o())->getToken($token);
|
|
||||||
if ($tok && $tok->id && $tok->type->value() === 'AUTH_TOKEN_SUBUSER') {
|
|
||||||
$resolvedId = (int)$tok->user_id->value();
|
|
||||||
// Re-cache mapping for future lookups (7 days to match session lifetime)
|
|
||||||
$this->cache($cache_key, $resolvedId, $cache_object_id);
|
|
||||||
$this->setCachedExpiration($cache_key, 7 * 24 * 60 * 60, $cache_object_id);
|
|
||||||
$subuser = (new subusers_o())->select($resolvedId);
|
|
||||||
$subuser->getObjectProperties();
|
|
||||||
return $subuser;
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Token not found or other error; treat as missing
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+406
-13
@@ -78,6 +78,8 @@ tags:
|
|||||||
description: License plate scanning operations
|
description: License plate scanning operations
|
||||||
- name: Config
|
- name: Config
|
||||||
description: Module configuration management
|
description: Module configuration management
|
||||||
|
- name: Release Manager
|
||||||
|
description: Release channel, deployment, and operation management
|
||||||
- name: Branding
|
- name: Branding
|
||||||
description: Branding options management
|
description: Branding options management
|
||||||
- name: Roles
|
- name: Roles
|
||||||
@@ -4071,13 +4073,15 @@ paths:
|
|||||||
- name: buttons
|
- name: buttons
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
|
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
|
||||||
schema:
|
schema:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
- name: current_step
|
- name: current_step
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@@ -5456,7 +5460,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Bulk save all-in-one self-serve studio graph changes
|
summary: Bulk save all-in-one self-serve studio graph changes
|
||||||
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
|
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
|
||||||
operationId: saveSelfserveStudioGraph
|
operationId: saveSelfserveStudioGraph
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -5613,6 +5617,29 @@ paths:
|
|||||||
'422':
|
'422':
|
||||||
$ref: '#/components/responses/BadRequest'
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
|
||||||
|
/department/selfserve/studio/path-confirmations:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Self-Serve
|
||||||
|
summary: Confirm or reset a projected self-serve studio path
|
||||||
|
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
|
||||||
|
operationId: confirmSelfserveStudioPath
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Path confirmation updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
|
||||||
/department/selfserve/studio/publish:
|
/department/selfserve/studio/publish:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -6186,7 +6213,7 @@ paths:
|
|||||||
reference: {type: string}
|
reference: {type: string}
|
||||||
po: {type: string}
|
po: {type: string}
|
||||||
pickup: {type: boolean}
|
pickup: {type: boolean}
|
||||||
order_id: {type: integer}
|
order_id: {type: integer, nullable: true}
|
||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -6212,6 +6239,32 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
|
|
||||||
|
/order-bookings/booking-confirmation/resend:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Bookings
|
||||||
|
summary: Resend order booking confirmation
|
||||||
|
description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department.
|
||||||
|
operationId: resendOrderBookingConfirmation
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id: {type: integer}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Booking confirmation resent successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
/order-bookings/complete:
|
/order-bookings/complete:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -8043,7 +8096,15 @@ paths:
|
|||||||
description: Worker status retrieved successfully
|
description: Worker status retrieved successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
api_commit_sha:
|
||||||
|
type: string
|
||||||
|
description: Running API commit SHA, or unknown when unavailable.
|
||||||
|
|
||||||
/worker/debug:
|
/worker/debug:
|
||||||
get:
|
get:
|
||||||
@@ -9295,7 +9356,10 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||||
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
|
Property gate commands require the matching explicit command permissions.
|
||||||
|
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||||
|
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||||
|
lanes. Customer `STOP` requires the customer's active self-serve wash in the target department.
|
||||||
operationId: sendSelfServeLaneCommand
|
operationId: sendSelfServeLaneCommand
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -9353,6 +9417,9 @@ paths:
|
|||||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||||
|
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||||
|
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||||
|
confirming a wash start.
|
||||||
operationId: setSelfServeLaneAllowedServices
|
operationId: setSelfServeLaneAllowedServices
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -9675,7 +9742,9 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||||
enabled; an explicit call to this endpoint is required.
|
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||||
|
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||||
|
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||||
operationId: enableSelfServeLaneMachineRelay
|
operationId: enableSelfServeLaneMachineRelay
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -12830,6 +12899,189 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/superuser/releases/operations:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: List release operation runs
|
||||||
|
operationId: listReleaseOperations
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: channel_id
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: operation_type
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Release operation runs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/superuser/releases/operations/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Get release operation details
|
||||||
|
operationId: getReleaseOperation
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Release operation details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/superuser/releases/test-runs:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Run Release Manager diagnostics
|
||||||
|
operationId: runReleaseTest
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Release test operation started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
|
||||||
|
/superuser/releases/channels/{id}/sync:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Sync latest branch commits into a release channel
|
||||||
|
operationId: syncReleaseChannel
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Channel sync operation started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
|
||||||
|
/superuser/releases/issues/actions:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Release Manager
|
||||||
|
summary: Run a Release Manager issue action
|
||||||
|
operationId: runReleaseIssueAction
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
issue_key:
|
||||||
|
type: string
|
||||||
|
action_id:
|
||||||
|
type: string
|
||||||
|
inputs:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
confirm:
|
||||||
|
type: boolean
|
||||||
|
additionalProperties: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Issue action result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -17695,10 +17947,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
action:
|
action:
|
||||||
type: string
|
type: string
|
||||||
enum: [create, update, delete, connect, disconnect, reorder]
|
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
|
||||||
entity:
|
entity:
|
||||||
type: string
|
type: string
|
||||||
enum: [question, condition, task]
|
enum: [question, condition, task, action, path]
|
||||||
description: Standalone rule operations are not accepted for schema_version 2 drafts.
|
description: Standalone rule operations are not accepted for schema_version 2 drafts.
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -17958,6 +18210,45 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
dynamic_image_buttons:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
decisions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [kind, id, label, state, reason, node_ids, causes]
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
nullable: true
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
node_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
causes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
additionalProperties: true
|
||||||
hardware:
|
hardware:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -18018,13 +18309,17 @@ components:
|
|||||||
max_states:
|
max_states:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
maximum: 2048
|
||||||
|
default: 2048
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Optional debug cap. Omit for complete path projection.
|
description: Optional debug cap for explored states. Omitted and larger values are capped at 2048.
|
||||||
path_sample_limit:
|
path_sample_limit:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
default: 200
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Optional debug cap for returned path rows. Omit to return every terminal path row.
|
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
||||||
|
|
||||||
SelfserveStudioPathOutcomesResponse:
|
SelfserveStudioPathOutcomesResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -18046,6 +18341,8 @@ components:
|
|||||||
items: { type: integer }
|
items: { type: integer }
|
||||||
max_states: { type: integer }
|
max_states: { type: integer }
|
||||||
path_sample_count: { type: integer }
|
path_sample_count: { type: integer }
|
||||||
|
confirmations:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||||
outcomes:
|
outcomes:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -18061,6 +18358,15 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
progress:
|
progress:
|
||||||
$ref: '#/components/schemas/SelfserveStudioPathProgress'
|
$ref: '#/components/schemas/SelfserveStudioPathProgress'
|
||||||
|
confirmations:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
summary:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||||
|
removed:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||||
|
|
||||||
SelfserveStudioPathProgress:
|
SelfserveStudioPathProgress:
|
||||||
type: object
|
type: object
|
||||||
@@ -18146,6 +18452,95 @@ components:
|
|||||||
node_ids:
|
node_ids:
|
||||||
type: array
|
type: array
|
||||||
items: { type: string }
|
items: { type: string }
|
||||||
|
path_signature: { type: string }
|
||||||
|
result_signature: { type: string }
|
||||||
|
confirmation_status:
|
||||||
|
type: string
|
||||||
|
enum: [unconfirmed, confirmed, stale]
|
||||||
|
confirmed_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
confirmed_by:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
stale_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
SelfserveStudioPathConfirmationSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
confirmed: { type: integer }
|
||||||
|
unconfirmed: { type: integer }
|
||||||
|
stale: { type: integer }
|
||||||
|
removed: { type: integer }
|
||||||
|
total: { type: integer }
|
||||||
|
|
||||||
|
SelfserveStudioPathConfirmationRequest:
|
||||||
|
type: object
|
||||||
|
required: [department, path_signature]
|
||||||
|
properties:
|
||||||
|
department: { type: integer }
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [confirm, reset, delete, clear]
|
||||||
|
default: confirm
|
||||||
|
path_signature: { type: string }
|
||||||
|
result_signature:
|
||||||
|
type: string
|
||||||
|
description: Required when action is confirm.
|
||||||
|
scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
answers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
SelfserveStudioPathConfirmation:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
department_id: { type: integer }
|
||||||
|
lane_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
vehicle_type_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
config_version_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
config_source: { type: string }
|
||||||
|
path_signature: { type: string }
|
||||||
|
result_signature: { type: string }
|
||||||
|
confirmation_status:
|
||||||
|
type: string
|
||||||
|
enum: [unconfirmed, confirmed, stale]
|
||||||
|
answers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
confirmed_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
confirmed_by:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
stale_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
SelfserveStudioPathTask:
|
SelfserveStudioPathTask:
|
||||||
type: object
|
type: object
|
||||||
@@ -20957,5 +21352,3 @@ components:
|
|||||||
success: { type: boolean, example: true }
|
success: { type: boolean, example: true }
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
|
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ FROM ${BASE_IMAGE}
|
|||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
|
|||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||||
|
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||||
|
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/truckwash-edge-agent
|
WORKDIR /opt/truckwash-edge-agent
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
|
|||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||||
|
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||||
|
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/truckwash-edge-agent
|
WORKDIR /opt/truckwash-edge-agent
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ final class OperationAbortException extends RuntimeException
|
|||||||
|
|
||||||
final class HttpJsonClient
|
final class HttpJsonClient
|
||||||
{
|
{
|
||||||
public function __construct(private readonly string $baseUrl)
|
public function __construct(private readonly string $baseUrl, private readonly array $defaultHeaders = [])
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ final class HttpJsonClient
|
|||||||
|
|
||||||
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
|
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
|
||||||
{
|
{
|
||||||
$headers = ['Accept: application/json'];
|
$headers = array_values(array_merge(['Accept: application/json'], $this->defaultHeaders));
|
||||||
if ($payload !== null) {
|
if ($payload !== null) {
|
||||||
$headers[] = 'Content-Type: application/json';
|
$headers[] = 'Content-Type: application/json';
|
||||||
}
|
}
|
||||||
@@ -1031,7 +1031,10 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
|
||||||
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
|
$this->workerHttp = new HttpJsonClient(
|
||||||
|
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
|
||||||
|
$this->workerAuthorizationHeaders()
|
||||||
|
);
|
||||||
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
|
||||||
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
|
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
|
||||||
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||||
@@ -2615,6 +2618,12 @@ final class TruckwashEdgeAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function workerAuthorizationHeaders(): array
|
||||||
|
{
|
||||||
|
$agentToken = trim((string)$this->config->get('agentToken', ''));
|
||||||
|
return $agentToken !== '' ? ['X-Truckwash-Worker-Token: ' . $agentToken] : [];
|
||||||
|
}
|
||||||
|
|
||||||
private function ensureAgentInstanceId(): string
|
private function ensureAgentInstanceId(): string
|
||||||
{
|
{
|
||||||
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
$configured = trim((string)$this->config->get('agentInstanceId', ''));
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ services:
|
|||||||
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
|
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
|
||||||
container_name: truckwash-redis
|
container_name: truckwash-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./runtime/redis:/data
|
- ./runtime/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" ping | grep -q PONG']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -19,10 +21,10 @@ services:
|
|||||||
container_name: truckwash-mariadb
|
container_name: truckwash-mariadb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MARIADB_DATABASE: truckwash_edge
|
MARIADB_DATABASE: ${MARIADB_DATABASE:-truckwash_edge}
|
||||||
MARIADB_USER: truckwash_edge
|
MARIADB_USER: ${MARIADB_USER:-truckwash_edge}
|
||||||
MARIADB_PASSWORD: truckwash_edge
|
MARIADB_PASSWORD: "${MARIADB_PASSWORD:?set MARIADB_PASSWORD}"
|
||||||
MARIADB_ROOT_PASSWORD: truckwash_edge_root
|
MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./runtime/mariadb:/var/lib/mysql
|
- ./runtime/mariadb:/var/lib/mysql
|
||||||
|
|
||||||
@@ -32,8 +34,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: truckwashminio
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"
|
||||||
MINIO_ROOT_PASSWORD: truckwash_edge_storage
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./runtime/minio:/data
|
- ./runtime/minio:/data
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./config.json:/config/config.json:ro
|
||||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ ACTION="${1:-up}"
|
|||||||
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
|
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
|
||||||
CONFIG_PATH="$INSTALL_DIR/config.json"
|
CONFIG_PATH="$INSTALL_DIR/config.json"
|
||||||
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
|
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
|
||||||
|
ENV_FILE="$INSTALL_DIR/.env"
|
||||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||||
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
|
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
|
||||||
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
|
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
|
||||||
@@ -64,6 +65,42 @@ ensure_dirs() {
|
|||||||
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_stack_env() {
|
||||||
|
php -r '
|
||||||
|
$path = $argv[1];
|
||||||
|
$content = is_file($path) ? (string)file_get_contents($path) : "";
|
||||||
|
$hasValue = static function (string $name) use ($content): bool {
|
||||||
|
if (!preg_match("/^" . preg_quote($name, "/") . "=(.*)$/m", $content, $matches)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return trim((string)$matches[1], " \t\"") !== "";
|
||||||
|
};
|
||||||
|
$secret = static function (int $bytes): string {
|
||||||
|
return rtrim(strtr(base64_encode(random_bytes($bytes)), "+/", "-_"), "=");
|
||||||
|
};
|
||||||
|
$generated = [];
|
||||||
|
$required = [
|
||||||
|
"REDIS_PASSWORD" => static fn(): string => $secret(32),
|
||||||
|
"MARIADB_PASSWORD" => static fn(): string => $secret(32),
|
||||||
|
"MARIADB_ROOT_PASSWORD" => static fn(): string => $secret(32),
|
||||||
|
"MINIO_ROOT_USER" => static fn(): string => "twminio" . bin2hex(random_bytes(12)),
|
||||||
|
"MINIO_ROOT_PASSWORD" => static fn(): string => $secret(32),
|
||||||
|
];
|
||||||
|
foreach ($required as $name => $factory) {
|
||||||
|
if (!$hasValue($name)) {
|
||||||
|
$generated[] = $name . "=" . $factory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($generated === []) {
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
$prefix = ($content !== "" && !str_ends_with($content, "\n")) ? "\n" : "";
|
||||||
|
file_put_contents($path, $prefix . implode("\n", $generated) . "\n", FILE_APPEND | LOCK_EX);
|
||||||
|
' "$ENV_FILE"
|
||||||
|
chmod 0600 "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
within_update_window() {
|
within_update_window() {
|
||||||
local window
|
local window
|
||||||
window="$(config_value updateWindow '02:00-04:00')"
|
window="$(config_value updateWindow '02:00-04:00')"
|
||||||
@@ -118,6 +155,7 @@ apply_stack() {
|
|||||||
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
|
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
|
||||||
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
|
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
|
||||||
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
||||||
|
ensure_stack_env
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
COMPOSE_PROJECT_NAME="$compose_project_name" \
|
COMPOSE_PROJECT_NAME="$compose_project_name" \
|
||||||
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
|
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
|
||||||
|
|||||||
@@ -20,6 +20,65 @@ function worker_read_json_body(): array
|
|||||||
return is_array($decoded) ? $decoded : [];
|
return is_array($decoded) ? $decoded : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worker_config_path(): string
|
||||||
|
{
|
||||||
|
$configuredPath = trim((string)getenv('TRUCKWASH_WORKER_CONFIG_PATH'));
|
||||||
|
return $configuredPath !== '' ? $configuredPath : '/config/config.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_expected_token(): string
|
||||||
|
{
|
||||||
|
$environmentToken = trim((string)getenv('TRUCKWASH_WORKER_TOKEN'));
|
||||||
|
if ($environmentToken !== '') {
|
||||||
|
return $environmentToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = worker_config_path();
|
||||||
|
if (!is_file($configPath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string)file_get_contents($configPath), true);
|
||||||
|
return is_array($decoded) ? trim((string)($decoded['agentToken'] ?? '')) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_request_token(): string
|
||||||
|
{
|
||||||
|
$headerToken = trim((string)($_SERVER['HTTP_X_TRUCKWASH_WORKER_TOKEN'] ?? ''));
|
||||||
|
if ($headerToken !== '') {
|
||||||
|
return $headerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$authorization = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||||||
|
if (str_starts_with(strtolower($authorization), 'bearer ')) {
|
||||||
|
return trim(substr($authorization, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function worker_require_authorization(): bool
|
||||||
|
{
|
||||||
|
$expectedToken = worker_expected_token();
|
||||||
|
if ($expectedToken === '') {
|
||||||
|
worker_json_response(503, [
|
||||||
|
'message' => 'LAN worker authorization is not configured',
|
||||||
|
'error_code' => 'EDGE_GATEWAY_WORKER_AUTH_UNCONFIGURED',
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash_equals($expectedToken, worker_request_token())) {
|
||||||
|
worker_json_response(401, [
|
||||||
|
'message' => 'Unauthorized',
|
||||||
|
'error_code' => 'EDGE_GATEWAY_WORKER_UNAUTHORIZED',
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
|
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
|
||||||
{
|
{
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
@@ -124,6 +183,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'POST' && $path === '/discover') {
|
if ($method === 'POST' && $path === '/discover') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
worker_json_response(200, [
|
worker_json_response(200, [
|
||||||
'inventory' => [[
|
'inventory' => [[
|
||||||
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
|
||||||
@@ -147,6 +210,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'POST' && $path === '/relay/status') {
|
if ($method === 'POST' && $path === '/relay/status') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
$channel = (int)($body['channel'] ?? 0);
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
|
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
|
||||||
@@ -155,6 +222,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'POST' && $path === '/relay/input-status') {
|
if ($method === 'POST' && $path === '/relay/input-status') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
$channel = (int)($body['channel'] ?? 0);
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
$input = worker_fetch_shelly_input_state($localIp, $channel);
|
$input = worker_fetch_shelly_input_state($localIp, $channel);
|
||||||
@@ -166,6 +237,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'POST' && $path === '/relay/switch') {
|
if ($method === 'POST' && $path === '/relay/switch') {
|
||||||
|
if (!worker_require_authorization()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||||
$channel = (int)($body['channel'] ?? 0);
|
$channel = (int)($body['channel'] ?? 0);
|
||||||
$on = (bool)($body['on'] ?? false);
|
$on = (bool)($body['on'] ?? false);
|
||||||
|
|||||||
@@ -835,8 +835,9 @@ class InvoicingPeriodRoute
|
|||||||
$response->add_meta('customer_numbers', $customerNumbers);
|
$response->add_meta('customer_numbers', $customerNumbers);
|
||||||
}
|
}
|
||||||
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
|
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
|
||||||
|
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
|
||||||
// Get the invoicing period for the user
|
// Get the invoicing period for the user
|
||||||
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);
|
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
|
||||||
if ($paginationOptions !== null) {
|
if ($paginationOptions !== null) {
|
||||||
$paginated = self::applyPeriodPagination($period, $paginationOptions);
|
$paginated = self::applyPeriodPagination($period, $paginationOptions);
|
||||||
$period = $paginated['period'];
|
$period = $paginated['period'];
|
||||||
@@ -1841,7 +1842,12 @@ class InvoicingPeriodRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
private static function getInvoicingPeriod(
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
?array $onlyCustomerNumbers = null,
|
||||||
|
bool $includeInvoicePeriodFlags = false
|
||||||
|
): array
|
||||||
{
|
{
|
||||||
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
|
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
|
||||||
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
||||||
@@ -1888,14 +1894,16 @@ class InvoicingPeriodRoute
|
|||||||
$draftOverlay['by_collection_id'] ?? [],
|
$draftOverlay['by_collection_id'] ?? [],
|
||||||
$draftOverlay['by_customer_number'] ?? [],
|
$draftOverlay['by_customer_number'] ?? [],
|
||||||
);
|
);
|
||||||
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
if ($includeInvoicePeriodFlags) {
|
||||||
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
||||||
$types,
|
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
||||||
$dateFrom,
|
$types,
|
||||||
$dateTo,
|
$dateFrom,
|
||||||
$onlyCustomerNumbers
|
$dateTo,
|
||||||
);
|
$onlyCustomerNumbers
|
||||||
}, 'invoice_period_flags');
|
);
|
||||||
|
}, 'invoice_period_flags');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'dateFrom' => $dateFrom,
|
'dateFrom' => $dateFrom,
|
||||||
'dateTo' => $dateTo,
|
'dateTo' => $dateTo,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class birdVoiceWebhooksRoute
|
|||||||
$this->post('/bird/voice/calls/webhook/inbound', function (): void {
|
$this->post('/bird/voice/calls/webhook/inbound', function (): void {
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
|
self::requirePermission('modules_bird_voice_call_webhooks_trigger');
|
||||||
$client = $this->resolveBirdClient();
|
$client = $this->resolveBirdClient();
|
||||||
$payload = $this->readInboundWebhookPayload();
|
$payload = $this->readInboundWebhookPayload();
|
||||||
|
|
||||||
|
|||||||
@@ -198,8 +198,7 @@ class bookingsRoute
|
|||||||
$this->post('/admin/bookings/sync', function () {
|
$this->post('/admin/bookings/sync', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
if ($this->fromRequest('auth_key') !== 'earm8BX4MFTgS6JCNQdqW5EzHUutv2Vx')
|
$this->requirePermission('sync_bookings');
|
||||||
$this->requirePermission('sync_bookings');
|
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
$booking = [
|
$booking = [
|
||||||
'id' => $this->fromRequest('id'),
|
'id' => $this->fromRequest('id'),
|
||||||
@@ -232,7 +231,7 @@ class bookingsRoute
|
|||||||
(string)$booking['washCertificateEmail'],
|
(string)$booking['washCertificateEmail'],
|
||||||
(string)$booking['date'],
|
(string)$booking['date'],
|
||||||
(string)$booking['department'],
|
(string)$booking['department'],
|
||||||
(string)$booking['pickup_bool'],
|
(int)$booking['pickup_bool'],
|
||||||
(string)$booking['notes'],
|
(string)$booking['notes'],
|
||||||
(string)$booking['washCertificateStatus'],
|
(string)$booking['washCertificateStatus'],
|
||||||
(string)$booking['washCertificateUrl'],
|
(string)$booking['washCertificateUrl'],
|
||||||
@@ -243,7 +242,7 @@ class bookingsRoute
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'sync_bookings' => 'Sync bookings from the external system NOTE: This permission is only required if the auth_key is not set'
|
'sync_bookings' => 'Sync bookings from the external system'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ class departmentDailyReportsRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((int)$complaint->department_id->value());
|
||||||
|
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'GET_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully retrieved department daily report complaint');
|
(new logs_o())->add('departments', 'global', 1, $user->id, 'GET_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully retrieved department daily report complaint');
|
||||||
|
|
||||||
$response->success($repository->parseComplaint($complaint->asArray()));
|
$response->success($repository->parseComplaint($complaint->asArray()));
|
||||||
@@ -524,7 +526,10 @@ class departmentDailyReportsRoute
|
|||||||
'created_at',
|
'created_at',
|
||||||
])
|
])
|
||||||
->listObjectsWithPaginationIfSet(
|
->listObjectsWithPaginationIfSet(
|
||||||
fn (array $complaint): array => $repository->parseComplaint($complaint)
|
fn (array $complaint): array => $repository->parseComplaint($complaint),
|
||||||
|
$repository->forceRestrictFilters([
|
||||||
|
'department_id' => $user->getGroup()->getDepartments(),
|
||||||
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -560,6 +565,8 @@ class departmentDailyReportsRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((int)$complaint->department_id->value());
|
||||||
|
|
||||||
$updates = [];
|
$updates = [];
|
||||||
|
|
||||||
if (self::isParametersSet(['department_id'])) {
|
if (self::isParametersSet(['department_id'])) {
|
||||||
@@ -578,6 +585,8 @@ class departmentDailyReportsRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((int)self::getParameter('department_id'));
|
||||||
|
|
||||||
$updates['department_id'] = (int)self::getParameter('department_id');
|
$updates['department_id'] = (int)self::getParameter('department_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +706,8 @@ class departmentDailyReportsRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((int)$complaint->department_id->value());
|
||||||
|
|
||||||
$complaint->deletePermanently();
|
$complaint->deletePermanently();
|
||||||
|
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully deleted department daily report complaint');
|
(new logs_o())->add('departments', 'global', 1, $user->id, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully deleted department daily report complaint');
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ class departmentLanesRoute
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
|
$this->get('/department/lanes/status-toggles', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('list_department_lanes');
|
||||||
|
self::requireParameters(['department_id']);
|
||||||
|
$department_id = (int)self::getParameter('department_id');
|
||||||
|
self::requireType($department_id, self::type_int());
|
||||||
|
self::requireMinValue($department_id, 1);
|
||||||
|
self::requireDepartmentAccess($department_id);
|
||||||
|
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if (!$user) {
|
||||||
|
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
|
||||||
|
$response->error('Invalid session', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
|
||||||
|
$response->success(
|
||||||
|
array_map(
|
||||||
|
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
|
||||||
|
(new department_lanes_o())->getDepartmentLanes($department_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'list_department_lanes' => 'List department lane status toggles'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->get('/department/lanes', function () {
|
$this->get('/department/lanes', function () {
|
||||||
|
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
@@ -36,6 +65,7 @@ class departmentLanesRoute
|
|||||||
// Return an error
|
// Return an error
|
||||||
$response->error('Department lane not found', 404);
|
$response->error('Department lane not found', 404);
|
||||||
}
|
}
|
||||||
|
self::requireDepartmentAccess((int)$department_lane->department->value());
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'VIEW_DEPARTMENT_LANE', 'User viewed department lane with id ' . $department_lane->id);
|
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'VIEW_DEPARTMENT_LANE', 'User viewed department lane with id ' . $department_lane->id);
|
||||||
// Return the department lane
|
// Return the department lane
|
||||||
@@ -68,7 +98,13 @@ class departmentLanesRoute
|
|||||||
$department_lane_o = (new department_lanes_o())->select((int)$department_lane['id']);
|
$department_lane_o = (new department_lanes_o())->select((int)$department_lane['id']);
|
||||||
// Return the object as an array
|
// Return the object as an array
|
||||||
return $department_lane_o->asArray();
|
return $department_lane_o->asArray();
|
||||||
}
|
},
|
||||||
|
(new department_lanes_o())->forceRestrictFilters(
|
||||||
|
[
|
||||||
|
// This makes sure that the user can only see lanes from departments they explicitly have access to
|
||||||
|
'department' => $user->getGroup()->getDepartments(),
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -135,7 +171,7 @@ class departmentLanesRoute
|
|||||||
* Query parameters:
|
* Query parameters:
|
||||||
* - department (int, required)
|
* - department (int, required)
|
||||||
* - lane (int, required)
|
* - lane (int, required)
|
||||||
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
|
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
|
||||||
* - current_step (int >= 0, optional) → current click/step indicator
|
* - current_step (int >= 0, optional) → current click/step indicator
|
||||||
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
|
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
|
||||||
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
|
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
|
||||||
@@ -230,21 +266,27 @@ class departmentLanesRoute
|
|||||||
// Cache check
|
// Cache check
|
||||||
$cacheKey = null;
|
$cacheKey = null;
|
||||||
if (defined('redis')) {
|
if (defined('redis')) {
|
||||||
$cacheParams = [
|
// Cache only the default image variant to avoid unbounded cache key growth
|
||||||
'dynamic_image_id' => $dynamic_image_id,
|
// from request-controlled parameters (buttons/current_step/etc.).
|
||||||
'buttons' => $buttons,
|
$isDefaultVariant = $buttons === null
|
||||||
'current_step' => $current_step,
|
&& $current_step === 0
|
||||||
'only_current_step' => (bool)$only_current_step,
|
&& !(bool)$only_current_step
|
||||||
'vehicle_type' => $vehicle_type,
|
&& $vehicle_type === null;
|
||||||
'thumb_position' => $thumb_position,
|
|
||||||
];
|
if ($isDefaultVariant) {
|
||||||
$cacheKey = 'dynamic_image_v2:' . md5(json_encode($cacheParams));
|
$cacheParams = [
|
||||||
$cachedImage = redis->get($cacheKey);
|
'department' => $department_id,
|
||||||
if ($cachedImage) {
|
'lane' => $lane_id,
|
||||||
header('Content-Type: image/png');
|
'dynamic_image_id' => $dynamic_image_id,
|
||||||
header('Content-Length: ' . strlen($cachedImage));
|
];
|
||||||
echo $cachedImage;
|
$cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams));
|
||||||
exit;
|
$cachedImage = redis->get($cacheKey);
|
||||||
|
if ($cachedImage) {
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($cachedImage));
|
||||||
|
echo $cachedImage;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +378,7 @@ class departmentLanesRoute
|
|||||||
}
|
}
|
||||||
// Check if the required fields are set
|
// Check if the required fields are set
|
||||||
if ($name && $department) {
|
if ($name && $department) {
|
||||||
|
self::requireDepartmentAccess((int)$department);
|
||||||
// Add the department lane
|
// Add the department lane
|
||||||
$created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled);
|
$created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled);
|
||||||
// Return a success message
|
// Return a success message
|
||||||
@@ -392,12 +435,14 @@ class departmentLanesRoute
|
|||||||
// Return an error
|
// Return an error
|
||||||
$response->error('Department lane not found', 404);
|
$response->error('Department lane not found', 404);
|
||||||
}
|
}
|
||||||
|
self::requireDepartmentAccess((int)$department_lane->department->value());
|
||||||
$was_selfserve_enabled = $department_lane->isSelfServeEnabled();
|
$was_selfserve_enabled = $department_lane->isSelfServeEnabled();
|
||||||
// Update the department lane fields that are set
|
// Update the department lane fields that are set
|
||||||
if (self::isParametersSet(['name'])) {
|
if (self::isParametersSet(['name'])) {
|
||||||
$department_lane->name->set($name);
|
$department_lane->name->set($name);
|
||||||
}
|
}
|
||||||
if (self::isParametersSet(['department'])) {
|
if (self::isParametersSet(['department'])) {
|
||||||
|
self::requireDepartmentAccess((int)$department);
|
||||||
$department_lane->department->set((int)$department);
|
$department_lane->department->set((int)$department);
|
||||||
}
|
}
|
||||||
if (self::isParametersSet(['relay_in_id'])) {
|
if (self::isParametersSet(['relay_in_id'])) {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class departmentSelfserveStudioRoute
|
|||||||
|
|
||||||
$this->post('/department/selfserve/studio/simulate', function (): void {
|
$this->post('/department/selfserve/studio/simulate', function (): void {
|
||||||
global $response;
|
global $response;
|
||||||
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
|
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||||
self::requireParameters(['department', 'lane_id', 'reg']);
|
self::requireParameters(['department', 'lane_id', 'reg']);
|
||||||
$departmentId = (int)self::getParameter('department');
|
$departmentId = (int)self::getParameter('department');
|
||||||
$laneId = (int)self::getParameter('lane_id');
|
$laneId = (int)self::getParameter('lane_id');
|
||||||
@@ -129,11 +129,11 @@ class departmentSelfserveStudioRoute
|
|||||||
$response->error($exception->getMessage(), 422);
|
$response->error($exception->getMessage(), 422);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator',
|
'list_department_selfserve_config_versions' => 'Run the self-serve studio simulator',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
|
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
|
||||||
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
|
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||||
self::requireParameters(['department']);
|
self::requireParameters(['department']);
|
||||||
$departmentId = (int)self::getParameter('department');
|
$departmentId = (int)self::getParameter('department');
|
||||||
$this->assertDepartmentAccess($user, $departmentId);
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
@@ -169,12 +169,12 @@ class departmentSelfserveStudioRoute
|
|||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}, [
|
}, [
|
||||||
'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress',
|
'list_department_selfserve_config_versions' => 'Stream grouped self-serve studio question path outcome progress',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
|
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
|
||||||
global $response;
|
global $response;
|
||||||
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
|
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||||
self::requireParameters(['department']);
|
self::requireParameters(['department']);
|
||||||
$departmentId = (int)self::getParameter('department');
|
$departmentId = (int)self::getParameter('department');
|
||||||
$this->assertDepartmentAccess($user, $departmentId);
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
@@ -192,7 +192,30 @@ class departmentSelfserveStudioRoute
|
|||||||
$response->error($exception->getMessage(), 422);
|
$response->error($exception->getMessage(), 422);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
|
'list_department_selfserve_config_versions' => 'Project grouped self-serve studio question path outcomes',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||||
|
global $response;
|
||||||
|
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||||
|
self::requireParameters(['department', 'path_signature']);
|
||||||
|
$departmentId = (int)self::getParameter('department');
|
||||||
|
$this->assertDepartmentAccess($user, $departmentId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = self::getParametersAsArray();
|
||||||
|
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
|
||||||
|
$service = new selfserve_studio_graph();
|
||||||
|
$result = in_array($action, ['delete', 'reset', 'clear'], true)
|
||||||
|
? $service->resetPathConfirmation($departmentId, $payload)
|
||||||
|
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
|
||||||
|
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
|
||||||
|
$response->success($result);
|
||||||
|
} catch (\RuntimeException $exception) {
|
||||||
|
$response->error($exception->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/department/selfserve/studio/publish', function (): void {
|
$this->post('/department/selfserve/studio/publish', function (): void {
|
||||||
@@ -306,6 +329,8 @@ class departmentSelfserveStudioRoute
|
|||||||
'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'),
|
'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'),
|
||||||
'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'),
|
'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'),
|
||||||
'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'),
|
'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'),
|
||||||
|
'can_add_department_lane' => $this->hasPermission('add_department_lane'),
|
||||||
|
'can_edit_department_lane' => $this->hasPermission('edit_department_lane'),
|
||||||
'modules_shelly_config' => $this->hasPermission('modules_shelly_config'),
|
'modules_shelly_config' => $this->hasPermission('modules_shelly_config'),
|
||||||
'can_manage_gateways' => $this->hasPermission('modules_shelly_config'),
|
'can_manage_gateways' => $this->hasPermission('modules_shelly_config'),
|
||||||
'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'),
|
'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'),
|
||||||
|
|||||||
@@ -493,11 +493,14 @@ class departmentSelfserveTasksRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachment = $task_o->getAttachment($attachment_id);
|
$task_attachments = $task_o->listAttachments();
|
||||||
if (!$attachment->exists()) {
|
$task_attachment_ids = array_map(static fn($attachment) => (int)$attachment->id, $task_attachments);
|
||||||
|
if (!in_array($attachment_id, $task_attachment_ids, true)) {
|
||||||
$response->error('Attachment not found', 404);
|
$response->error('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$attachment = $task_o->getAttachment($attachment_id);
|
||||||
|
|
||||||
$attachment_store = new attachment_store();
|
$attachment_store = new attachment_store();
|
||||||
$attachments = new attachments();
|
$attachments = new attachments();
|
||||||
$attachment_formatted = $attachments->format($attachment);
|
$attachment_formatted = $attachments->format($attachment);
|
||||||
@@ -620,6 +623,12 @@ class departmentSelfserveTasksRoute
|
|||||||
$this->forbidDepartmentAccess((int)$task_o->department->value());
|
$this->forbidDepartmentAccess((int)$task_o->department->value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$task_attachments = $task_o->listAttachments();
|
||||||
|
$task_attachment_ids = array_map(static fn($attachment) => (int)$attachment->id, $task_attachments);
|
||||||
|
if (!in_array($attachment_id, $task_attachment_ids, true)) {
|
||||||
|
$response->error('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
$task_o->removeAttachment($attachment_id);
|
$task_o->removeAttachment($attachment_id);
|
||||||
|
|
||||||
(new logs_o())->add('department_selfserve_tasks', (int)$task_o->department->value(), 1, $user->id, 'DELETE_TASK_ATTACHMENT', 'User deleted attachment ID: ' . $attachment_id . ' for task ID: ' . $task_id);
|
(new logs_o())->add('department_selfserve_tasks', (int)$task_o->department->value(), 1, $user->id, 'DELETE_TASK_ATTACHMENT', 'User deleted attachment ID: ' . $attachment_id . ' for task ID: ' . $task_id);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$lane_id = (int)self::getParameter('lane_id');
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||||
|
|
||||||
$lane = $this->assertLaneAccess($user, $lane_id, $has_global);
|
$lane = $this->assertLaneAccess($user, $lane_id);
|
||||||
$customer_number = null;
|
$customer_number = null;
|
||||||
if (!$has_global && $has_own) {
|
if (!$has_global && $has_own) {
|
||||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||||
@@ -193,7 +193,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$lane_id = (int)self::getParameter('lane_id');
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||||
|
|
||||||
$this->assertLaneAccess($user, $lane_id, $has_global);
|
$this->assertLaneAccess($user, $lane_id);
|
||||||
$customer_number = null;
|
$customer_number = null;
|
||||||
if (!$has_global && $has_own) {
|
if (!$has_global && $has_own) {
|
||||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||||
@@ -527,7 +527,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o
|
private function assertLaneAccess(object $user, int $laneId): department_lanes_o
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
@@ -536,11 +536,14 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$response->error('Department lane not found', 404);
|
$response->error('Department lane not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasGlobalPermission) {
|
$lane_department_id = (int)$lane->department->value();
|
||||||
$authorized_department_ids = $user->getGroup()->getDepartments();
|
$authorized_department_ids = array_values(array_filter(
|
||||||
if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) {
|
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
||||||
$this->forbidDepartmentAccess((int)$lane->department->value());
|
static fn(int $department_id): bool => $department_id > 0
|
||||||
}
|
));
|
||||||
|
|
||||||
|
if (!in_array($lane_department_id, $authorized_department_ids, true)) {
|
||||||
|
$this->forbidDepartmentAccess($lane_department_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $lane;
|
return $lane;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class departmentsRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string
|
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class departmentsRoute
|
|||||||
$filters['visible'] = 1;
|
$filters['visible'] = 1;
|
||||||
$filters['archived'] = $archived;
|
$filters['archived'] = $archived;
|
||||||
|
|
||||||
return $departments->array_to_filters($filters);
|
return $filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function isTruthyBooleanValue(mixed $value): bool
|
private static function isTruthyBooleanValue(mixed $value): bool
|
||||||
@@ -307,6 +307,8 @@ class departmentsRoute
|
|||||||
// Return an error
|
// Return an error
|
||||||
$response->error('Department not found', 404);
|
$response->error('Department not found', 404);
|
||||||
}
|
}
|
||||||
|
// Check if the user has access to the department
|
||||||
|
self::requireDepartmentAccess((string)$department->id);
|
||||||
// Get the department variable
|
// Get the department variable
|
||||||
$department_variables = (new department_variables_o())->selectDepartment($department->id);
|
$department_variables = (new department_variables_o())->selectDepartment($department->id);
|
||||||
$enabled = $department_variables->getVariable('selfserve_enabled');
|
$enabled = $department_variables->getVariable('selfserve_enabled');
|
||||||
@@ -321,7 +323,8 @@ class departmentsRoute
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'view_department_selfserve_enabled' => 'View if department self-serve is enabled'
|
'view_department_selfserve_enabled' => 'View if department self-serve is enabled',
|
||||||
|
'department_access_:id' => 'Access the department'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -345,7 +348,7 @@ class departmentsRoute
|
|||||||
$response->error('Department not found', 404);
|
$response->error('Department not found', 404);
|
||||||
}
|
}
|
||||||
// Check if the user has access to the department
|
// Check if the user has access to the department
|
||||||
self::requireDepartmentAccess($department);
|
self::requireDepartmentAccess((string)$department->id);
|
||||||
// Set the department variable
|
// Set the department variable
|
||||||
$department_variables = (new department_variables_o())->selectDepartment($department->id);
|
$department_variables = (new department_variables_o())->selectDepartment($department->id);
|
||||||
$enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1';
|
$enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1';
|
||||||
@@ -554,7 +557,6 @@ class departmentsRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
|
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
|
||||||
{
|
{
|
||||||
if (!$enabled) {
|
if (!$enabled) {
|
||||||
@@ -579,13 +581,13 @@ class departmentsRoute
|
|||||||
|
|
||||||
// Self-serve enabled: keep machine stack off.
|
// Self-serve enabled: keep machine stack off.
|
||||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||||
$lane->setMachineProgramPickerRelayStatusHard(false);
|
$lane->setMachineProgramPickerRelayStatus(false);
|
||||||
});
|
});
|
||||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||||
$lane->setMachineCleanerRelayStatusHard(false);
|
$lane->setMachineCleanerRelayStatus(false);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
$lane->setMachineRelayStatusHard(false);
|
$lane->setMachineRelayStatus(false);
|
||||||
} catch (\Throwable) {}
|
} catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class economicInvoiceRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$job = $queue->getJobById((int)$job_id);
|
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
|
||||||
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
|
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
|
||||||
$response->error('Draft export queue job not found', 404);
|
$response->error('Draft export queue job not found', 404);
|
||||||
}
|
}
|
||||||
@@ -264,13 +264,13 @@ class economicInvoiceRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$existing_job = $queue->getJobById((int)$job_id);
|
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
|
||||||
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
|
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
|
||||||
$response->error('Draft export queue job not found', 404);
|
$response->error('Draft export queue job not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$job = $queue->retryJob((int)$job_id);
|
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$response->error($e->getMessage(), 400);
|
$response->error($e->getMessage(), 400);
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ class economicInvoiceRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$job = $queue->getJobById((int)$job_id);
|
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
|
||||||
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
|
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
|
||||||
$response->error('Invoice export queue job not found', 404);
|
$response->error('Invoice export queue job not found', 404);
|
||||||
}
|
}
|
||||||
@@ -323,13 +323,13 @@ class economicInvoiceRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$existing_job = $queue->getJobById((int)$job_id);
|
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
|
||||||
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
|
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
|
||||||
$response->error('Invoice export queue job not found', 404);
|
$response->error('Invoice export queue job not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$job = $queue->retryJob((int)$job_id);
|
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$response->error($e->getMessage(), 400);
|
$response->error($e->getMessage(), 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ class moduleLimbleRoute
|
|||||||
$this->post('/modules/limble/webhook/task', function () {
|
$this->post('/modules/limble/webhook/task', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$slack = new slack();
|
$slack = new slack();
|
||||||
//TODO: Add authentication of some sort here
|
self::requirePermission('modules_limble_webhooks_task');
|
||||||
//self::requirePermission('modules_limble_webhooks_task');
|
|
||||||
// Check if the module is enabled
|
// Check if the module is enabled
|
||||||
$limble = new limble();
|
$limble = new limble();
|
||||||
$limble->requireModuleEnabled();
|
$limble->requireModuleEnabled();
|
||||||
@@ -45,7 +44,7 @@ class moduleLimbleRoute
|
|||||||
global $response;
|
global $response;
|
||||||
$slack = new slack();
|
$slack = new slack();
|
||||||
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
|
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
|
||||||
//self::requirePermission('modules_limble_tasks');
|
self::requirePermission('modules_limble_tasks');
|
||||||
// Check if the module is enabled
|
// Check if the module is enabled
|
||||||
$limble = new limble();
|
$limble = new limble();
|
||||||
$limble->requireModuleEnabled();
|
$limble->requireModuleEnabled();
|
||||||
@@ -61,4 +60,4 @@ class moduleLimbleRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class moduleSelfServeRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
|
private const MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5;
|
||||||
|
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */
|
global /** @var response $response */
|
||||||
@@ -60,6 +63,60 @@ class moduleSelfServeRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->put('/modules/self-serve/lane/status', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
self::requirePermission('modules_selfserve_lane_status_set');
|
||||||
|
self::requireParameters(['lane_id', 'enabled']);
|
||||||
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
|
self::requireType($lane_id, self::type_int());
|
||||||
|
self::requireMinValue($lane_id, 1);
|
||||||
|
|
||||||
|
$department_lane = (new department_lanes_o())->select($lane_id);
|
||||||
|
if (!$department_lane->exists()) {
|
||||||
|
$response->error('Department lane not found', 404);
|
||||||
|
}
|
||||||
|
self::requireDepartmentAccess((int)$department_lane->department->value());
|
||||||
|
|
||||||
|
$enabled = $this->requestedBoolean('enabled');
|
||||||
|
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
|
||||||
|
$lane = (new selfserve())->lane($lane_id);
|
||||||
|
$lane->setLaneStatus($target_status);
|
||||||
|
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
$machine_status_audit = [
|
||||||
|
'modified_at' => date(DATE_ATOM),
|
||||||
|
'modified_by_user_id' => $user ? (int)$user->id : null,
|
||||||
|
'modified_by_name' => $this->machineStatusAuditUserName($user),
|
||||||
|
];
|
||||||
|
$lane->setLaneStatusAudit($machine_status_audit);
|
||||||
|
|
||||||
|
(new logs_o())->add(
|
||||||
|
'selfserve',
|
||||||
|
'global',
|
||||||
|
1,
|
||||||
|
$user ? $user->id : 0,
|
||||||
|
'SET_LANE_MACHINE_STATUS',
|
||||||
|
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = (string)$lane->getLaneStatus()->name;
|
||||||
|
$response->success([
|
||||||
|
'id' => $lane->id,
|
||||||
|
'status' => $status,
|
||||||
|
'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
|
||||||
|
'machine_status_audit' => $machine_status_audit,
|
||||||
|
'machine_status_modified_at' => $machine_status_audit['modified_at'],
|
||||||
|
'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
|
||||||
|
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
|
||||||
|
'lane' => $department_lane->asArray(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
/** Modules > Self Serve > Lane > Wash > In-progress details */
|
/** Modules > Self Serve > Lane > Wash > In-progress details */
|
||||||
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
|
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
|
||||||
global $response;
|
global $response;
|
||||||
@@ -67,7 +124,7 @@ class moduleSelfServeRoute
|
|||||||
$lane_id = (int)$this->getParameter('lane_id');
|
$lane_id = (int)$this->getParameter('lane_id');
|
||||||
self::requireType($lane_id, self::type_int());
|
self::requireType($lane_id, self::type_int());
|
||||||
self::requireMinValue($lane_id, 1);
|
self::requireMinValue($lane_id, 1);
|
||||||
$customer_scope = $this->requireInProgressWashDetailsAccess();
|
$customer_scope = $this->requireInProgressWashDetailsAccess($lane_id);
|
||||||
|
|
||||||
$build_customer = static function (?int $customer_number): ?array {
|
$build_customer = static function (?int $customer_number): ?array {
|
||||||
if ($customer_number === null || $customer_number <= 0) {
|
if ($customer_number === null || $customer_number <= 0) {
|
||||||
@@ -389,7 +446,6 @@ class moduleSelfServeRoute
|
|||||||
/** Modules > Self Serve > Lane > Command */
|
/** Modules > Self Serve > Lane > Command */
|
||||||
$this->post('/modules/self-serve/lane/command', function () {
|
$this->post('/modules/self-serve/lane/command', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute');
|
|
||||||
$selfserve = new selfserve();
|
$selfserve = new selfserve();
|
||||||
// Get the request user
|
// Get the request user
|
||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
@@ -402,55 +458,96 @@ class moduleSelfServeRoute
|
|||||||
self::requireMinValue($lane_id, 1);
|
self::requireMinValue($lane_id, 1);
|
||||||
$commandParam = (string)$this->getParameter($param_command);
|
$commandParam = (string)$this->getParameter($param_command);
|
||||||
self::requireType($commandParam, self::type_string());
|
self::requireType($commandParam, self::type_string());
|
||||||
// Get the lane and command
|
|
||||||
$lane = $selfserve->lane($lane_id);
|
|
||||||
// If the user has the bypass permission, set the lane to bypass customer number validation
|
|
||||||
if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) {
|
|
||||||
$lane->setBypassCustomerNumberValidation(true);
|
|
||||||
}
|
|
||||||
$command = selfserve_lane_command::tryFrom($commandParam);
|
$command = selfserve_lane_command::tryFrom($commandParam);
|
||||||
if ($command === null) {
|
if ($command === null) {
|
||||||
$response->error("Invalid command: " . $commandParam);
|
$response->error("Invalid command: " . $commandParam);
|
||||||
}
|
}
|
||||||
|
// Get the lane and command
|
||||||
|
$lane = $selfserve->lane($lane_id);
|
||||||
|
// Preserve not-found behavior before evaluating elevated/customer alternatives.
|
||||||
|
if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
|
||||||
|
$response->error('Lane department not found', 404);
|
||||||
|
}
|
||||||
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
|
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
|
||||||
|
[
|
||||||
|
$allow_customer_self_serve,
|
||||||
|
$requires_active_wash,
|
||||||
|
$allow_department_active_wash
|
||||||
|
] = $this->customerSelfServeCommandAccessRequirements($command);
|
||||||
|
$this->requireSelfServeLaneDepartmentOrCustomerAccess(
|
||||||
|
$lane,
|
||||||
|
$customer_number,
|
||||||
|
$allow_customer_self_serve,
|
||||||
|
$requires_active_wash,
|
||||||
|
$allow_department_active_wash
|
||||||
|
);
|
||||||
|
// If the user has the bypass permission, set the lane to bypass customer number validation
|
||||||
|
if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) {
|
||||||
|
$lane->setBypassCustomerNumberValidation(true);
|
||||||
|
}
|
||||||
// Require permissions for specific commands
|
// Require permissions for specific commands
|
||||||
switch ($command) {
|
switch ($command) {
|
||||||
case selfserve_lane_command::START:
|
case selfserve_lane_command::START:
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute_start');
|
$this->requireSelfServeLaneCommandPermission(
|
||||||
|
$lane,
|
||||||
|
$customer_number,
|
||||||
|
'modules_selfserve_lane_command_execute_start',
|
||||||
|
true
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::STOP:
|
case selfserve_lane_command::STOP:
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute_stop');
|
$this->requireSelfServeLaneCommandPermission(
|
||||||
|
$lane,
|
||||||
|
$customer_number,
|
||||||
|
'modules_selfserve_lane_command_execute_stop',
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RESERVE:
|
case selfserve_lane_command::RESERVE:
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
|
$this->requireOperatorLaneCommandPermission(
|
||||||
|
$lane,
|
||||||
|
'modules_selfserve_lane_command_execute_reserve'
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RELEASE:
|
case selfserve_lane_command::RELEASE:
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute_release');
|
$this->requireOperatorLaneCommandPermission(
|
||||||
|
$lane,
|
||||||
|
'modules_selfserve_lane_command_execute_release'
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RESET:
|
case selfserve_lane_command::RESET:
|
||||||
self::requirePermission('modules_selfserve_lane_command_execute_reset');
|
$this->requireOperatorLaneCommandPermission(
|
||||||
|
$lane,
|
||||||
|
'modules_selfserve_lane_command_execute_reset'
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||||
$this->requirePropertyGateCommandPermission(
|
$this->requirePropertyGateCommandPermission(
|
||||||
'modules_selfserve_lane_command_execute_open_property_access_gate',
|
'modules_selfserve_lane_command_execute_open_property_access_gate',
|
||||||
$lane,
|
$lane,
|
||||||
(int)$user->customer_number->value()
|
$customer_number
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||||
$this->requirePropertyGateCommandPermission(
|
$this->requirePropertyGateCommandPermission(
|
||||||
'modules_selfserve_lane_command_execute_open_property_exit_gate',
|
'modules_selfserve_lane_command_execute_open_property_exit_gate',
|
||||||
$lane,
|
$lane,
|
||||||
(int)$user->customer_number->value()
|
$customer_number
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Execute the command
|
// Execute the command
|
||||||
try {
|
try {
|
||||||
$this->applyShellyTransportOverride($lane);
|
$this->applyShellyTransportOverride($lane);
|
||||||
|
$subuser = (new authentication())->get_subuser();
|
||||||
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
|
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
|
||||||
$args->setParameters([
|
$args->setParameters([
|
||||||
...$this->getParametersAsArray(), // Pass all parameters
|
...$this->getParametersAsArray(), // Pass all parameters
|
||||||
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
|
'customer_number' => $customer_number, // Get customer number from request user
|
||||||
|
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
|
||||||
]);
|
]);
|
||||||
$lane->execute($command, $args);
|
$lane->execute($command, $args);
|
||||||
$response->success([
|
$response->success([
|
||||||
@@ -493,7 +590,6 @@ class moduleSelfServeRoute
|
|||||||
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
|
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
|
||||||
$this->post('/modules/self-serve/lane/services/allowed', function () {
|
$this->post('/modules/self-serve/lane/services/allowed', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
|
||||||
$selfserve = new selfserve();
|
$selfserve = new selfserve();
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
self::requireParameters(['lane_id']);
|
self::requireParameters(['lane_id']);
|
||||||
@@ -516,6 +612,8 @@ class moduleSelfServeRoute
|
|||||||
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
|
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
|
||||||
// Build allowed services from provided tasks
|
// Build allowed services from provided tasks
|
||||||
$lane = $selfserve->lane($lane_id);
|
$lane = $selfserve->lane($lane_id);
|
||||||
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
|
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||||
$allowed_services = [];
|
$allowed_services = [];
|
||||||
foreach ($task_ids as $tid) {
|
foreach ($task_ids as $tid) {
|
||||||
if ($tid <= 0) continue;
|
if ($tid <= 0) continue;
|
||||||
@@ -835,7 +933,6 @@ class moduleSelfServeRoute
|
|||||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
|
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
|
||||||
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
|
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
|
|
||||||
$selfserve = new selfserve();
|
$selfserve = new selfserve();
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
self::requireParameters(['lane_id']);
|
self::requireParameters(['lane_id']);
|
||||||
@@ -848,6 +945,8 @@ class moduleSelfServeRoute
|
|||||||
self::requireMinValue($duration, 1);
|
self::requireMinValue($duration, 1);
|
||||||
}
|
}
|
||||||
$lane = $selfserve->lane($lane_id);
|
$lane = $selfserve->lane($lane_id);
|
||||||
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
|
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
|
||||||
try {
|
try {
|
||||||
$this->applyShellyTransportOverride($lane);
|
$this->applyShellyTransportOverride($lane);
|
||||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
|
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
|
||||||
@@ -1143,7 +1242,7 @@ class moduleSelfServeRoute
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireInProgressWashDetailsAccess(): ?int
|
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
@@ -1153,6 +1252,11 @@ class moduleSelfServeRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) {
|
if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) {
|
||||||
|
$department_lane = (new department_lanes_o())->select($lane_id);
|
||||||
|
if (!$department_lane->exists()) {
|
||||||
|
$response->error('Department lane not found', 404);
|
||||||
|
}
|
||||||
|
self::requireDepartmentAccess((string)$department_lane->department->value());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,9 +1375,145 @@ class moduleSelfServeRoute
|
|||||||
$lane->setShellyTransportOverride($transport);
|
$lane->setShellyTransportOverride($transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed]
|
||||||
|
*/
|
||||||
|
private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array
|
||||||
|
{
|
||||||
|
return match ($command) {
|
||||||
|
selfserve_lane_command::START => [true, false, false],
|
||||||
|
selfserve_lane_command::STOP => [true, true, false],
|
||||||
|
selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE,
|
||||||
|
selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true],
|
||||||
|
selfserve_lane_command::RESERVE,
|
||||||
|
selfserve_lane_command::RELEASE,
|
||||||
|
selfserve_lane_command::RESET => [false, false, false],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireSelfServeLaneDepartmentOrCustomerAccess(
|
||||||
|
selfserve_lane $lane,
|
||||||
|
int $customer_number,
|
||||||
|
bool $allow_customer_self_serve,
|
||||||
|
bool $requires_active_wash = false,
|
||||||
|
bool $allow_department_active_wash = false
|
||||||
|
): void {
|
||||||
|
$department_id = $this->departmentIdForLane($lane);
|
||||||
|
if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allow_customer_self_serve) {
|
||||||
|
$customer_allowed = $requires_active_wash
|
||||||
|
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
||||||
|
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
|
|
||||||
|
if ($customer_allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
|
||||||
|
if ($allow_customer_self_serve) {
|
||||||
|
$missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION;
|
||||||
|
}
|
||||||
|
$this->emitForbidden($missing_permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $permissions
|
||||||
|
*/
|
||||||
|
private function hasAllPermissions(array $permissions): bool
|
||||||
|
{
|
||||||
|
foreach ($permissions as $permission) {
|
||||||
|
if (!$this->hasPermission($permission)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $elevated_permissions
|
||||||
|
*/
|
||||||
|
private function requireSelfServeLaneAccess(
|
||||||
|
selfserve_lane $lane,
|
||||||
|
int $customer_number,
|
||||||
|
array $elevated_permissions,
|
||||||
|
bool $requires_active_wash = false,
|
||||||
|
bool $requires_operational_lane = true
|
||||||
|
): void {
|
||||||
|
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requires_active_wash) {
|
||||||
|
$customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||||
|
&& (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
|
||||||
|
} else {
|
||||||
|
$customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer_allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireSelfServeLaneCommandPermission(
|
||||||
|
selfserve_lane $lane,
|
||||||
|
int $customer_number,
|
||||||
|
string $command_permission,
|
||||||
|
bool $allow_customer_self_serve,
|
||||||
|
bool $requires_active_wash = false,
|
||||||
|
bool $allow_department_active_wash = false
|
||||||
|
): void {
|
||||||
|
$elevated_permissions = [
|
||||||
|
'modules_selfserve_lane_command_execute',
|
||||||
|
$command_permission,
|
||||||
|
];
|
||||||
|
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) {
|
||||||
|
$customer_allowed = $requires_active_wash
|
||||||
|
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
||||||
|
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
|
|
||||||
|
if ($customer_allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emitForbidden(
|
||||||
|
$allow_customer_self_serve
|
||||||
|
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
|
||||||
|
: $elevated_permissions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireOperatorLaneCommandPermission(selfserve_lane $lane, string $command_permission): void
|
||||||
|
{
|
||||||
|
if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
|
||||||
|
global $response;
|
||||||
|
$response->error('Lane department not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requireDepartmentAccess((string)$lane->department_lane->department->value());
|
||||||
|
self::requirePermission('modules_selfserve_lane_command_execute');
|
||||||
|
self::requirePermission($command_permission);
|
||||||
|
}
|
||||||
|
|
||||||
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
|
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
|
||||||
{
|
{
|
||||||
if (self::hasPermission($permission)) {
|
$elevated_permissions = [
|
||||||
|
'modules_selfserve_lane_command_execute',
|
||||||
|
$permission,
|
||||||
|
];
|
||||||
|
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,21 +1521,133 @@ class moduleSelfServeRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::requirePermission($permission);
|
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isSelfServeModuleEnabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (bool)(new selfserve())->config->enabled->getVariableValue();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
|
{
|
||||||
|
return $customer_number > 0
|
||||||
|
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
|
||||||
|
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
|
{
|
||||||
|
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Fall back to the persisted session lookup below.
|
||||||
|
}
|
||||||
|
|
||||||
|
$department_id = $this->departmentIdForLane($lane);
|
||||||
|
return $department_id > 0
|
||||||
|
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
if ($customer_number <= 0) {
|
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||||
|
selfserve_lane $lane,
|
||||||
|
int $customer_number,
|
||||||
|
bool $allow_department_active_wash = false
|
||||||
|
): bool {
|
||||||
|
return $this->isLaneSelfServeOperationallyEnabled($lane)
|
||||||
|
&& (
|
||||||
|
$allow_department_active_wash
|
||||||
|
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||||
|
: $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
|
||||||
|
{
|
||||||
|
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$department_id = (int)$lane->department_lane?->department?->value();
|
try {
|
||||||
|
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Fall back to the persisted lane session lookup below.
|
||||||
|
}
|
||||||
|
|
||||||
|
$active_statuses = array_map(
|
||||||
|
static fn(selfserve_wash_session_status $status): string => $status->value,
|
||||||
|
[
|
||||||
|
selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
|
||||||
|
selfserve_wash_session_status::READY_FOR_MACHINE_START,
|
||||||
|
selfserve_wash_session_status::MACHINE_STARTED,
|
||||||
|
selfserve_wash_session_status::PENDING_QUESTIONS,
|
||||||
|
selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
|
||||||
|
'lane_id' => (int)$lane->id,
|
||||||
|
'customer_number' => $customer_number,
|
||||||
|
'completed_at' => null,
|
||||||
|
'deleted_at' => null,
|
||||||
|
], ['id', 'status']);
|
||||||
|
|
||||||
|
foreach ($sessions as $session) {
|
||||||
|
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$department_id = $this->departmentIdForLane($lane);
|
||||||
if ($department_id <= 0) {
|
if ($department_id <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
try {
|
||||||
|
$department = (new departments_o())->select($department_id);
|
||||||
|
return $department->exists() && $department->getSelfServeEnabled();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function departmentIdForLane(selfserve_lane $lane): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (int)$lane->department_lane?->department?->value();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||||
@@ -1348,7 +1700,6 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requestedShellyTransportOverride(): ?string
|
private function requestedShellyTransportOverride(): ?string
|
||||||
{
|
{
|
||||||
$transport = null;
|
$transport = null;
|
||||||
@@ -1386,9 +1737,37 @@ class moduleSelfServeRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
self::requireMinValue($toggle_after, 1);
|
self::requireMinValue($toggle_after, 1);
|
||||||
|
self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS);
|
||||||
return $toggle_after;
|
return $toggle_after;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function machineStatusAuditUserName(?object $user): ?string
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['display_name', 'email'] as $property) {
|
||||||
|
if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string)$user->{$property}->value());
|
||||||
|
if ($value !== '' && strtolower($value) !== 'unnamed') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
|
||||||
|
$customer_number = (int)$user->customer_number->value();
|
||||||
|
if ($customer_number > 0) {
|
||||||
|
return 'Kunde ' . $customer_number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function requestedBoolean(string $parameter, bool $default = false): bool
|
private function requestedBoolean(string $parameter, bool $default = false): bool
|
||||||
{
|
{
|
||||||
if (!self::isParametersSet([$parameter])) {
|
if (!self::isParametersSet([$parameter])) {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class moduleXLVaskRoute
|
|||||||
|
|
||||||
$this->get('/modules/xlvask/tasks/sync-usage', function () {
|
$this->get('/modules/xlvask/tasks/sync-usage', function () {
|
||||||
global $response;
|
global $response;
|
||||||
//self::requirePermission('modules_xlvask_sync_usage');
|
self::requirePermission('modules_xlvask_sync_usage');
|
||||||
// Remove the memory limit
|
// Remove the memory limit
|
||||||
// ini_set('memory_limit', '-1');
|
// ini_set('memory_limit', '-1');
|
||||||
// Remove the execution time limit
|
// Remove the execution time limit
|
||||||
|
|||||||
@@ -4,28 +4,20 @@ namespace routes;
|
|||||||
|
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/classes/cors_policy.php';
|
||||||
|
|
||||||
class optionsRoute
|
class optionsRoute
|
||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// When the OPTIONS method is requested, accept all using regex
|
|
||||||
$this->options('/.*', function () {
|
$this->options('/.*', function () {
|
||||||
global $CORS;
|
global $CORS;
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
http_response_code($preflight['status']);
|
||||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
echo $preflight['body'];
|
||||||
header("Access-Control-Allow-Credentials: true");
|
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *');
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
http_response_code(200);
|
|
||||||
} else {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['success' => false, 'message' => 'CORS origin not allowed']);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\email;
|
||||||
use classes\order_bookings_counts_cache;
|
use classes\order_bookings_counts_cache;
|
||||||
use classes\order_bookings_list_cache;
|
use classes\order_bookings_list_cache;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
@@ -12,6 +13,7 @@ use objects\departments_o;
|
|||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\order_bookings_o;
|
use objects\order_bookings_o;
|
||||||
use objects\order_items_o;
|
use objects\order_items_o;
|
||||||
|
use objects\orders_o;
|
||||||
use objects\products_o;
|
use objects\products_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
@@ -265,6 +267,7 @@ class orderBookingRoute
|
|||||||
$po = self::getTargetPo(false); // String | Null
|
$po = self::getTargetPo(false); // String | Null
|
||||||
$pickup = self::getTargetPickup(false); // Bool | Null
|
$pickup = self::getTargetPickup(false); // Bool | Null
|
||||||
$items = self::getTargetItems(false); // Array of order_items_o objects
|
$items = self::getTargetItems(false); // Array of order_items_o objects
|
||||||
|
$order_id_was_set = self::isParametersSet(['order_id']);
|
||||||
$order_id = self::getTargetOrderId(false); // Int | Null
|
$order_id = self::getTargetOrderId(false); // Int | Null
|
||||||
/** Authentication */
|
/** Authentication */
|
||||||
$auth = new authentication();
|
$auth = new authentication();
|
||||||
@@ -293,6 +296,7 @@ class orderBookingRoute
|
|||||||
/**
|
/**
|
||||||
* Update the object
|
* Update the object
|
||||||
*/
|
*/
|
||||||
|
$previous_order_id = (int)($object->order_id->value() ?? 0);
|
||||||
$data = [
|
$data = [
|
||||||
...(self::hasPermission($permission_other) && !empty($customer_number) ? [
|
...(self::hasPermission($permission_other) && !empty($customer_number) ? [
|
||||||
'customer_number' => (int)$customer_number->customer_number->value(),
|
'customer_number' => (int)$customer_number->customer_number->value(),
|
||||||
@@ -307,9 +311,12 @@ class orderBookingRoute
|
|||||||
...(isset($po) ? ['po' => $po] : []),
|
...(isset($po) ? ['po' => $po] : []),
|
||||||
...(isset($pickup) ? ['pickup' => $pickup] : []),
|
...(isset($pickup) ? ['pickup' => $pickup] : []),
|
||||||
...(isset($items) ? ['items' => $items] : []),
|
...(isset($items) ? ['items' => $items] : []),
|
||||||
...(isset($order_id) ? ['order_id' => $order_id] : []),
|
...($order_id_was_set ? ['order_id' => $order_id] : []),
|
||||||
];
|
];
|
||||||
$object->update($data);
|
$object->update($data);
|
||||||
|
if ($order_id_was_set && $order_id === null) {
|
||||||
|
self::clearMatchingOrderBookingLink($object, $previous_order_id);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Return the object
|
* Return the object
|
||||||
*/
|
*/
|
||||||
@@ -362,6 +369,29 @@ class orderBookingRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->post('/order-bookings/booking-confirmation/resend', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$object = self::getTargetObject();
|
||||||
|
if (!$object || !$object->exists()) {
|
||||||
|
$response->error('Order booking does not exist.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requirePermission('resend_booking_confirmations');
|
||||||
|
self::requireDepartmentAccess((int)$object->department->value());
|
||||||
|
|
||||||
|
(new email())->sendOrderBookingConfirmationEmail($object);
|
||||||
|
|
||||||
|
$response->success([
|
||||||
|
'message' => 'Booking confirmation resent successfully.',
|
||||||
|
'booking' => $object->asArray(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
'resend_booking_confirmations' => 'Permission for department admins to resend order booking confirmation emails.'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$this->post('/order-bookings/complete', function () {
|
$this->post('/order-bookings/complete', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
@@ -529,7 +559,7 @@ class orderBookingRoute
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getTargetOrderId(bool $required = true): int|string|null
|
private function getTargetOrderId(bool $required = true): int|null
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
$parameter = 'order_id';
|
$parameter = 'order_id';
|
||||||
@@ -541,7 +571,7 @@ class orderBookingRoute
|
|||||||
} else {
|
} else {
|
||||||
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
|
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
|
||||||
// Check if the value is null
|
// Check if the value is null
|
||||||
if ($this->getParameter($parameter) === null) return "null";
|
if ($this->getParameter($parameter) === null) return null;
|
||||||
}
|
}
|
||||||
self::requireMinLength($parameter, 1);
|
self::requireMinLength($parameter, 1);
|
||||||
self::requireMaxLength($parameter, 9);
|
self::requireMaxLength($parameter, 9);
|
||||||
@@ -551,6 +581,28 @@ class orderBookingRoute
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void
|
||||||
|
{
|
||||||
|
if ($previous_order_id <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous_order = (new orders_o())->select($previous_order_id);
|
||||||
|
if (!$previous_order->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous_order->booking_id->set(null);
|
||||||
|
$previous_order->objectChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private function getTargetCustomer(bool $required = true): users_o|null
|
private function getTargetCustomer(bool $required = true): users_o|null
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -752,13 +752,14 @@ class orderInvoicesRoute
|
|||||||
['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination();
|
['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination();
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$jobs = $queue->listJobs(
|
$jobs = $queue->listJobsForCreatedBy(
|
||||||
$statuses,
|
$statuses,
|
||||||
$limit,
|
$limit,
|
||||||
$offset,
|
$offset,
|
||||||
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||||
|
(int)$user->id
|
||||||
);
|
);
|
||||||
$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses);
|
$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses, (int)$user->id);
|
||||||
$has_more = ($offset + count($jobs)) < $total_jobs;
|
$has_more = ($offset + count($jobs)) < $total_jobs;
|
||||||
|
|
||||||
$response->success([
|
$response->success([
|
||||||
@@ -785,7 +786,7 @@ class orderInvoicesRoute
|
|||||||
$this->ensureEconomicTransferQueueIsAvailable();
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
||||||
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
|
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id);
|
||||||
|
|
||||||
$response->success($this->withCollectedInvoiceQueueDetailsSummary($job));
|
$response->success($this->withCollectedInvoiceQueueDetailsSummary($job));
|
||||||
},
|
},
|
||||||
@@ -827,14 +828,14 @@ class orderInvoicesRoute
|
|||||||
$this->ensureEconomicTransferQueueIsAvailable();
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
||||||
$job = $this->requireCollectedInvoiceQueueJobById($job_id, true);
|
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id, true);
|
||||||
if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) {
|
if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) {
|
||||||
$response->error('Collected invoice queue job reached max retry attempts', 409);
|
$response->error('Collected invoice queue job reached max retry attempts', 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$retried = $queue->retryJob($job_id);
|
$retried = $queue->retryJobForUser($job_id, (int)$user->id);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$message = trim((string)$e->getMessage());
|
$message = trim((string)$e->getMessage());
|
||||||
$status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message);
|
$status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message);
|
||||||
@@ -861,7 +862,7 @@ class orderInvoicesRoute
|
|||||||
$this->ensureEconomicTransferQueueIsAvailable();
|
$this->ensureEconomicTransferQueueIsAvailable();
|
||||||
|
|
||||||
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
$job_id = $this->requireCollectedInvoiceQueueJobId();
|
||||||
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
|
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id);
|
||||||
$status = strtoupper((string)($job['status'] ?? ''));
|
$status = strtoupper((string)($job['status'] ?? ''));
|
||||||
if (!in_array($status, [
|
if (!in_array($status, [
|
||||||
economic_transfer_queue::STATUS_COMPLETED,
|
economic_transfer_queue::STATUS_COMPLETED,
|
||||||
@@ -1816,6 +1817,7 @@ class orderInvoicesRoute
|
|||||||
$warnings = [];
|
$warnings = [];
|
||||||
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
|
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
|
||||||
$invoice->requireSelected();
|
$invoice->requireSelected();
|
||||||
|
$this->requireCollectedInvoiceContextAccess($invoice);
|
||||||
|
|
||||||
$draft_id = null;
|
$draft_id = null;
|
||||||
$booked_id = null;
|
$booked_id = null;
|
||||||
@@ -1935,6 +1937,56 @@ class orderInvoicesRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
|
||||||
|
{
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
if ($this->hasPermission('superuser')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice_customer_number = (int)$invoice->customer_number->value();
|
||||||
|
if ($invoice_customer_number > 0 && $this->isOwnCustomerContext($invoice_customer_number)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasAccessToAllCollectedInvoiceDepartments((int)$invoice->id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->error('Permission denied for requested collected invoice.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasAccessToAllCollectedInvoiceDepartments(int $collected_invoice_id): bool
|
||||||
|
{
|
||||||
|
$orders = new orders_o();
|
||||||
|
$order_departments = $orders->getFieldsWhere(
|
||||||
|
[
|
||||||
|
'invoice_collection_id' => $collected_invoice_id,
|
||||||
|
'deleted_at' => null,
|
||||||
|
],
|
||||||
|
['department_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
$department_ids = array_values(array_unique(array_filter(array_map(static function (array $order): int {
|
||||||
|
return (int)($order['department_id'] ?? 0);
|
||||||
|
}, $order_departments), static function (int $department_id): bool {
|
||||||
|
return $department_id > 0;
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (empty($department_ids)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($department_ids as $department_id) {
|
||||||
|
if (!$this->hasDepartmentAccess((string)$department_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int
|
private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int
|
||||||
{
|
{
|
||||||
if ($invoice_raw === null) {
|
if ($invoice_raw === null) {
|
||||||
@@ -2153,12 +2205,12 @@ class orderInvoicesRoute
|
|||||||
return (int)$job_id;
|
return (int)$job_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array
|
private function requireCollectedInvoiceQueueJobById(int $job_id, int $created_by, bool $mustBeFailed = false): array
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$queue = new economic_transfer_queue();
|
$queue = new economic_transfer_queue();
|
||||||
$job = $queue->getJobById($job_id);
|
$job = $queue->getJobByIdForUser($job_id, $created_by);
|
||||||
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
|
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||||
$response->error('Collected invoice queue job not found', 404);
|
$response->error('Collected invoice queue job not found', 404);
|
||||||
}
|
}
|
||||||
@@ -2263,19 +2315,26 @@ class orderInvoicesRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int
|
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses, int $created_by): int
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
if (method_exists($queue, 'countJobs')) {
|
$created_by = max(0, $created_by);
|
||||||
return max(0, (int)$queue->countJobs(
|
if ($created_by < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($queue, 'countJobsForCreatedBy')) {
|
||||||
|
return max(0, (int)$queue->countJobsForCreatedBy(
|
||||||
$statuses,
|
$statuses,
|
||||||
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||||
|
$created_by
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$conditions = [
|
$conditions = [
|
||||||
"transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'",
|
"transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'",
|
||||||
|
'created_by = ' . $created_by,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($statuses !== []) {
|
if ($statuses !== []) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use classes\authentication;
|
|||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\order_items_o;
|
use objects\order_items_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
|
use objects\products_o;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class orderItemsRoute
|
class orderItemsRoute
|
||||||
@@ -69,6 +70,13 @@ class orderItemsRoute
|
|||||||
$price = (int)self::getParameter('price');
|
$price = (int)self::getParameter('price');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$product = (new products_o())->getProductById((int)$data['product_id']);
|
||||||
|
if (!$product->exists()) {
|
||||||
|
$response->error('Product not found', 404);
|
||||||
|
}
|
||||||
|
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||||
|
$response->error('Notes is required for this product', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
||||||
$order_items = (new order_items_o());
|
$order_items = (new order_items_o());
|
||||||
@@ -203,6 +211,26 @@ class orderItemsRoute
|
|||||||
if (!isset($data['quantity'])) {
|
if (!isset($data['quantity'])) {
|
||||||
$response->error('Quantity is required', 400);
|
$response->error('Quantity is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
|
||||||
|
if (!$orderItem->exists()) {
|
||||||
|
$response->error('Order item not found', 404);
|
||||||
|
}
|
||||||
|
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
|
||||||
|
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
||||||
|
$response->error('Notes is required for this product', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
|
||||||
|
if (!$order->exists()) {
|
||||||
|
$response->error('Order not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
|
||||||
|
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
|
||||||
|
$response->error('Order item does not belong to the user', 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the order item
|
// Update the order item
|
||||||
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
|
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
|
||||||
// Log the incident
|
// Log the incident
|
||||||
|
|||||||
@@ -198,7 +198,9 @@ class ordersRoute
|
|||||||
$po = $this->resolveOrderPoForBookingDefault(
|
$po = $this->resolveOrderPoForBookingDefault(
|
||||||
array_key_exists('po', $data) ? $data['po'] : null,
|
array_key_exists('po', $data) ? $data['po'] : null,
|
||||||
array_key_exists('po', $data),
|
array_key_exists('po', $data),
|
||||||
$bookingId
|
$bookingId,
|
||||||
|
(int)$data['customer_id'],
|
||||||
|
(int)$data['department_id']
|
||||||
);
|
);
|
||||||
$new_data = [
|
$new_data = [
|
||||||
'customer_id' => (int)$data['customer_id'],
|
'customer_id' => (int)$data['customer_id'],
|
||||||
@@ -605,6 +607,7 @@ class ordersRoute
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 400);
|
$response->error('Order not found', 400);
|
||||||
}
|
}
|
||||||
|
self::requireDepartmentAccess((int)$order->department_id->value());
|
||||||
|
|
||||||
$department = (new departments_o())->selectId((int)$order->department_id->value());
|
$department = (new departments_o())->selectId((int)$order->department_id->value());
|
||||||
if (!$department->isStripeConfigured()) {
|
if (!$department->isStripeConfigured()) {
|
||||||
@@ -626,6 +629,7 @@ class ordersRoute
|
|||||||
|
|
||||||
$stripe = new stripe();
|
$stripe = new stripe();
|
||||||
$stripePaymentIntents = new stripe_payment_intents_o();
|
$stripePaymentIntents = new stripe_payment_intents_o();
|
||||||
|
$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0);
|
||||||
|
|
||||||
if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
||||||
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
|
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
|
||||||
@@ -638,7 +642,10 @@ class ordersRoute
|
|||||||
$stripePaymentIntents->tax_percentage->set($tax_percentage);
|
$stripePaymentIntents->tax_percentage->set($tax_percentage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isStripePaymentIntentReusable($storedPaymentIntent)) {
|
if (
|
||||||
|
$this->isStripePaymentIntentReusable($storedPaymentIntent)
|
||||||
|
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0)
|
||||||
|
) {
|
||||||
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')');
|
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')');
|
||||||
$response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [
|
$response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [
|
||||||
'reused' => true,
|
'reused' => true,
|
||||||
@@ -652,10 +659,7 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$paymentIntent = $stripe->payment_intents->create(
|
$paymentIntent = $stripe->payment_intents->create(
|
||||||
(int)round($this->addTaxNetAmount(
|
$expectedPaymentIntentAmount,
|
||||||
(float)$order->getNetAmount() * 100,
|
|
||||||
$tax_percentage ?? 0
|
|
||||||
)),
|
|
||||||
[
|
[
|
||||||
'description' => 'Order ID: ' . $order->id,
|
'description' => 'Order ID: ' . $order->id,
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
@@ -730,6 +734,7 @@ class ordersRoute
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 400);
|
$response->error('Order not found', 400);
|
||||||
}
|
}
|
||||||
|
self::requireDepartmentAccess((int)$order->department_id->value());
|
||||||
|
|
||||||
$stripePaymentIntents = new stripe_payment_intents_o();
|
$stripePaymentIntents = new stripe_payment_intents_o();
|
||||||
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
||||||
@@ -788,6 +793,7 @@ class ordersRoute
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 400);
|
$response->error('Order not found', 400);
|
||||||
}
|
}
|
||||||
|
self::requireDepartmentAccess((int)$order->department_id->value());
|
||||||
|
|
||||||
$stripePaymentIntents = new stripe_payment_intents_o();
|
$stripePaymentIntents = new stripe_payment_intents_o();
|
||||||
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
|
||||||
@@ -947,6 +953,35 @@ class ordersRoute
|
|||||||
return $net_amount + ($net_amount * ($tax_percentage / 100));
|
return $net_amount + ($net_amount * ($tax_percentage / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getStripePaymentIntentAmountForOrder(orders_o $order, ?int $tax_percentage): int
|
||||||
|
{
|
||||||
|
return (int)round($this->addTaxNetAmount(
|
||||||
|
(float)$order->getNetAmount() * 100,
|
||||||
|
$tax_percentage ?? 0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function doesStripePaymentIntentMatchOrder(object $paymentIntent, int $expectedAmount, ?int $tax_percentage): bool
|
||||||
|
{
|
||||||
|
if (!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $paymentIntent->metadata ?? null;
|
||||||
|
$storedTaxPercentage = null;
|
||||||
|
if (is_array($metadata)) {
|
||||||
|
$storedTaxPercentage = $metadata['tax_percentage'] ?? null;
|
||||||
|
} elseif (is_object($metadata)) {
|
||||||
|
$storedTaxPercentage = $metadata->tax_percentage ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storedTaxPercentage === null || !is_numeric($storedTaxPercentage)) {
|
||||||
|
return ($tax_percentage ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$storedTaxPercentage === ($tax_percentage ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
private function isStripePaymentIntentReusable(object $paymentIntent): bool
|
private function isStripePaymentIntentReusable(object $paymentIntent): bool
|
||||||
{
|
{
|
||||||
$status = strtolower((string)($paymentIntent->status ?? ''));
|
$status = strtolower((string)($paymentIntent->status ?? ''));
|
||||||
@@ -1137,6 +1172,9 @@ class ordersRoute
|
|||||||
);
|
);
|
||||||
$order->customer_id->set($newCustomerNumber);
|
$order->customer_id->set($newCustomerNumber);
|
||||||
}
|
}
|
||||||
|
$targetCustomerNumber = isset($data['customer_id'])
|
||||||
|
? (int)$data['customer_id']
|
||||||
|
: (int)$order->customer_id->value();
|
||||||
// If the reference is set, validate it
|
// If the reference is set, validate it
|
||||||
if (isset($data['reference'])) {
|
if (isset($data['reference'])) {
|
||||||
$order->reference->set($data['reference']);
|
$order->reference->set($data['reference']);
|
||||||
@@ -1191,7 +1229,17 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
// Check if the invoice collection is set
|
// Check if the invoice collection is set
|
||||||
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
|
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
|
||||||
$order->invoice_collection_id->set((int)$data['invoice_collection_id']);
|
$invoiceCollectionId = (int)$data['invoice_collection_id'];
|
||||||
|
if ($invoiceCollectionId > 0) {
|
||||||
|
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||||
|
if (!$invoiceCollection->exists()) {
|
||||||
|
$response->error('Invoice collection not found', 400);
|
||||||
|
}
|
||||||
|
if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) {
|
||||||
|
$response->error('Invoice collection does not belong to the order customer', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$order->invoice_collection_id->set($invoiceCollectionId);
|
||||||
}
|
}
|
||||||
// Check if the wash_id is set
|
// Check if the wash_id is set
|
||||||
if (isset($data['wash_id'])) {
|
if (isset($data['wash_id'])) {
|
||||||
@@ -1235,14 +1283,20 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOrderPoForBookingDefault(mixed $po, bool $poProvided, ?int $bookingId): ?string
|
private function resolveOrderPoForBookingDefault(
|
||||||
|
mixed $po,
|
||||||
|
bool $poProvided,
|
||||||
|
?int $bookingId,
|
||||||
|
int $customerNumber,
|
||||||
|
int $departmentId
|
||||||
|
): ?string
|
||||||
{
|
{
|
||||||
$currentPo = is_scalar($po) || $po === null ? trim((string)$po) : '';
|
$currentPo = is_scalar($po) || $po === null ? trim((string)$po) : '';
|
||||||
if ($currentPo !== '') {
|
if ($currentPo !== '') {
|
||||||
return $currentPo;
|
return $currentPo;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bookingPo = $this->getBookingPoDefault($bookingId);
|
$bookingPo = $this->getBookingPoDefault($bookingId, $customerNumber, $departmentId);
|
||||||
if ($bookingPo !== null) {
|
if ($bookingPo !== null) {
|
||||||
return $bookingPo;
|
return $bookingPo;
|
||||||
}
|
}
|
||||||
@@ -1257,7 +1311,11 @@ class ordersRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bookingPo = $this->getBookingPoDefault($bookingId ?? (int)($order->booking_id->value() ?? 0));
|
$bookingPo = $this->getBookingPoDefault(
|
||||||
|
$bookingId ?? (int)($order->booking_id->value() ?? 0),
|
||||||
|
(int)$order->customer_id->value(),
|
||||||
|
(int)$order->department_id->value()
|
||||||
|
);
|
||||||
if ($bookingPo === null) {
|
if ($bookingPo === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1265,9 +1323,13 @@ class ordersRoute
|
|||||||
$order->po->set($bookingPo);
|
$order->po->set($bookingPo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getBookingPoDefault(?int $bookingId): ?string
|
private function getBookingPoDefault(?int $bookingId, int $customerNumber, int $departmentId): ?string
|
||||||
{
|
{
|
||||||
if ($bookingId === null || $bookingId <= 0) {
|
if ($bookingId === null || $bookingId <= 0 || $customerNumber <= 0 || $departmentId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->canUseBookingPoDefault($customerNumber, $departmentId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1277,6 +1339,18 @@ class ordersRoute
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int)$booking->customer_number->value() !== $customerNumber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$booking->department->value() !== $departmentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string)($booking->deleted_at->value() ?? '')) !== '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$bookingPo = trim((string)($booking->po->value() ?? ''));
|
$bookingPo = trim((string)($booking->po->value() ?? ''));
|
||||||
return $bookingPo !== '' ? $bookingPo : null;
|
return $bookingPo !== '' ? $bookingPo : null;
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -1284,6 +1358,20 @@ class ordersRoute
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canUseBookingPoDefault(int $customerNumber, int $departmentId): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$user = (new authentication())->get_user();
|
||||||
|
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hasDepartmentAccess((string)$departmentId);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeLegacyEditableFieldPayload(array $data, response $response): array
|
private function normalizeLegacyEditableFieldPayload(array $data, response $response): array
|
||||||
{
|
{
|
||||||
if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) {
|
if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user