Compare commits
273
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 |
+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
|
||||||
|
|||||||
+2
-2
@@ -53,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
|
|
||||||
@@ -9,17 +9,18 @@ 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
|
- name: Mark repository as safe for Git
|
||||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
- name: Prepare Qodana cache directories
|
- name: Prepare Qodana cache directories
|
||||||
@@ -48,4 +49,4 @@ jobs:
|
|||||||
|
|
||||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
|
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 }}
|
||||||
@@ -258,7 +258,7 @@ jobs:
|
|||||||
|
|
||||||
release-manager-gate:
|
release-manager-gate:
|
||||||
name: Release Manager gate
|
name: Release Manager gate
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-latest
|
||||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
@@ -267,16 +267,36 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
curl --fail --show-error --silent \
|
response_file="$(mktemp)"
|
||||||
|
http_code="$(curl --show-error --silent \
|
||||||
--connect-timeout 10 \
|
--connect-timeout 10 \
|
||||||
--retry 5 \
|
--retry 5 \
|
||||||
--retry-all-errors \
|
--retry-all-errors \
|
||||||
--retry-delay 15 \
|
--retry-delay 15 \
|
||||||
--retry-max-time 300 \
|
--retry-max-time 300 \
|
||||||
|
-o "$response_file" \
|
||||||
|
-w '%{http_code}' \
|
||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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\":[]}"
|
--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:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
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_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
|||||||
@@ -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; \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5874,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:
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -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\}/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class release_manager
|
|||||||
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
|
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
|
||||||
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
|
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
|
||||||
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
|
||||||
|
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
|
||||||
|
private const RELEASE_GATE_MAX_PATHS = 10;
|
||||||
|
private const RELEASE_GATE_MAX_ASSETS = 50;
|
||||||
private const RELEASE_API_RUNTIME_ENV_KEYS = [
|
private const RELEASE_API_RUNTIME_ENV_KEYS = [
|
||||||
'USE_ENV',
|
'USE_ENV',
|
||||||
'DEBUG',
|
'DEBUG',
|
||||||
@@ -1018,6 +1021,7 @@ class release_manager
|
|||||||
'channel_slug' => $channelSlug,
|
'channel_slug' => $channelSlug,
|
||||||
'route_slug' => $routeSlug,
|
'route_slug' => $routeSlug,
|
||||||
'app' => $app,
|
'app' => $app,
|
||||||
|
'apps' => $this->releaseTestAppsFromInput($input),
|
||||||
'repository' => $repository,
|
'repository' => $repository,
|
||||||
'branch' => $branch,
|
'branch' => $branch,
|
||||||
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
|
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
|
||||||
@@ -1031,9 +1035,10 @@ class release_manager
|
|||||||
'api_ping_paths' => $this->releaseGateStringArray(
|
'api_ping_paths' => $this->releaseGateStringArray(
|
||||||
$input['api_ping_paths']
|
$input['api_ping_paths']
|
||||||
?? $input['api_paths']
|
?? $input['api_paths']
|
||||||
?? ['/master/api/ping']
|
?? ['/master/api/ping'],
|
||||||
|
self::RELEASE_GATE_MAX_PATHS
|
||||||
),
|
),
|
||||||
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']),
|
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,6 +1069,7 @@ class release_manager
|
|||||||
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
|
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
|
||||||
{
|
{
|
||||||
$steps = [];
|
$steps = [];
|
||||||
|
$requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : [];
|
||||||
$context = [
|
$context = [
|
||||||
'channel_slug' => $gateInput['channel_slug'] ?? null,
|
'channel_slug' => $gateInput['channel_slug'] ?? null,
|
||||||
'app' => $gateInput['app'] ?? null,
|
'app' => $gateInput['app'] ?? null,
|
||||||
@@ -1071,6 +1077,7 @@ class release_manager
|
|||||||
'branch' => $gateInput['branch'] ?? null,
|
'branch' => $gateInput['branch'] ?? null,
|
||||||
'expected_commit' => $gateInput['expected_commit'] ?? null,
|
'expected_commit' => $gateInput['expected_commit'] ?? null,
|
||||||
'workflow_url' => $gateInput['workflow_url'] ?? null,
|
'workflow_url' => $gateInput['workflow_url'] ?? null,
|
||||||
|
'required_checks' => $requiredChecks,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($channel === null) {
|
if ($channel === null) {
|
||||||
@@ -1106,6 +1113,17 @@ class release_manager
|
|||||||
'context' => $context,
|
'context' => $context,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if ($requiredChecks === []) {
|
||||||
|
$steps[] = [
|
||||||
|
'step_key' => 'auto_sync_required_checks',
|
||||||
|
'label' => 'Automatic update required checks',
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Automatic container updates require at least one release gate check.',
|
||||||
|
'diagnostic' => 'required_checks was empty.',
|
||||||
|
'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.',
|
||||||
|
'context' => $context,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($steps === []) {
|
if ($steps === []) {
|
||||||
$steps[] = [
|
$steps[] = [
|
||||||
@@ -1120,7 +1138,7 @@ class release_manager
|
|||||||
return $steps;
|
return $steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function releaseGateStringArray(mixed $value): array
|
private function releaseGateStringArray(mixed $value, int $limit = 50): array
|
||||||
{
|
{
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
$value = preg_split('/\s*,\s*/', trim($value)) ?: [];
|
$value = preg_split('/\s*,\s*/', trim($value)) ?: [];
|
||||||
@@ -1136,7 +1154,7 @@ class release_manager
|
|||||||
$values[] = $item;
|
$values[] = $item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $values;
|
return array_slice($values, 0, max(0, $limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeReleaseGateUrl(string $value): string
|
private function normalizeReleaseGateUrl(string $value): string
|
||||||
@@ -1376,14 +1394,14 @@ class release_manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$assetUrls = $this->releaseGateUniqueStrings(array_merge(
|
$assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
|
||||||
['release-manifest.json', 'release-entry.json'],
|
['release-manifest.json', 'release-entry.json'],
|
||||||
[(string)($manifestData['entry'] ?? '')],
|
[(string)($manifestData['entry'] ?? '')],
|
||||||
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
|
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
|
||||||
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
|
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
|
||||||
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
|
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
|
||||||
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
|
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
|
||||||
));
|
)), 0, self::RELEASE_GATE_MAX_ASSETS);
|
||||||
$verifiedAssets = 0;
|
$verifiedAssets = 0;
|
||||||
foreach ($assetUrls as $assetUrl) {
|
foreach ($assetUrls as $assetUrl) {
|
||||||
if ($assetUrl === '/index.html') {
|
if ($assetUrl === '/index.html') {
|
||||||
@@ -1502,15 +1520,18 @@ class release_manager
|
|||||||
|
|
||||||
private function releaseGateFetch(string $url): array
|
private function releaseGateFetch(string $url): array
|
||||||
{
|
{
|
||||||
|
$this->assertReleaseGateFetchUrlAllowed($url);
|
||||||
|
|
||||||
$curl = curl_init($url);
|
$curl = curl_init($url);
|
||||||
if ($curl === false) {
|
if ($curl === false) {
|
||||||
throw new RuntimeException('Could not initialize release gate request.');
|
throw new RuntimeException('Could not initialize release gate request.');
|
||||||
}
|
}
|
||||||
|
|
||||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
|
||||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
|
curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
|
||||||
curl_setopt($curl, CURLOPT_TIMEOUT, 15);
|
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
|
||||||
|
curl_setopt($curl, CURLOPT_TIMEOUT, 8);
|
||||||
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
||||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||||
'Accept: application/json, text/html, */*',
|
'Accept: application/json, text/html, */*',
|
||||||
@@ -1539,13 +1560,82 @@ class release_manager
|
|||||||
|
|
||||||
private function releaseGateJoinUrl(string $baseUrl, string $path): string
|
private function releaseGateJoinUrl(string $baseUrl, string $path): string
|
||||||
{
|
{
|
||||||
if (preg_match('#^https?://#i', $path) === 1) {
|
$path = trim($path);
|
||||||
return $path;
|
$parts = parse_url($path);
|
||||||
|
if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
|
||||||
|
throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
|
||||||
|
}
|
||||||
|
if (str_starts_with($path, '//')) {
|
||||||
|
throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
|
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function assertReleaseGateFetchUrlAllowed(string $url): void
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||||
|
throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
|
||||||
|
}
|
||||||
|
if (!$this->releaseGateFetchHostAllowed($host)) {
|
||||||
|
throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$addresses = $this->releaseGateResolveHost($host);
|
||||||
|
if ($addresses === []) {
|
||||||
|
throw new RuntimeException('Release gate host could not be resolved.');
|
||||||
|
}
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
if (!$this->releaseGatePublicIpAllowed($address)) {
|
||||||
|
throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseGateFetchHostAllowed(string $host): bool
|
||||||
|
{
|
||||||
|
$host = strtolower(rtrim($host, '.'));
|
||||||
|
foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
|
||||||
|
$allowedSuffix = strtolower($allowedSuffix);
|
||||||
|
if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseGateResolveHost(string $host): array
|
||||||
|
{
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||||
|
return [$host];
|
||||||
|
}
|
||||||
|
|
||||||
|
$addresses = gethostbynamel($host) ?: [];
|
||||||
|
if (function_exists('dns_get_record')) {
|
||||||
|
foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
|
||||||
|
if (is_array($record) && !empty($record['ipv6'])) {
|
||||||
|
$addresses[] = (string)$record['ipv6'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseGatePublicIpAllowed(string $address): bool
|
||||||
|
{
|
||||||
|
return filter_var(
|
||||||
|
$address,
|
||||||
|
FILTER_VALIDATE_IP,
|
||||||
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||||
|
) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
private function releaseGateCommitMatches(string $actual, string $expected): bool
|
private function releaseGateCommitMatches(string $actual, string $expected): bool
|
||||||
{
|
{
|
||||||
$expected = strtolower(trim($expected));
|
$expected = strtolower(trim($expected));
|
||||||
@@ -4713,22 +4803,22 @@ class release_manager
|
|||||||
throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
|
throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
|
||||||
}
|
}
|
||||||
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
|
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
|
||||||
|
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
|
||||||
|
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
|
||||||
$this->assertReleaseGatePassedForPromotion(
|
$this->assertReleaseGatePassedForPromotion(
|
||||||
$channelId,
|
$channelId,
|
||||||
(string)($bundle['frontend_commit_sha'] ?? ''),
|
(string)($bundle['frontend_commit_sha'] ?? ''),
|
||||||
null,
|
null,
|
||||||
'frontend'
|
'frontend'
|
||||||
);
|
);
|
||||||
if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') {
|
if ($apiVersionId !== null) {
|
||||||
$this->assertReleaseGatePassedForPromotion(
|
$this->assertReleaseGatePassedForPromotion(
|
||||||
$channelId,
|
$channelId,
|
||||||
(string)$bundle['api_commit_sha'],
|
(string)($bundle['api_commit_sha'] ?? ''),
|
||||||
null,
|
null,
|
||||||
'api'
|
'api'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
|
|
||||||
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
|
|
||||||
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
|
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
|
||||||
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
|
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
|
||||||
|
|
||||||
@@ -9343,7 +9433,7 @@ class release_manager
|
|||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'commit_mode' => $input['commit_mode'],
|
'commit_mode' => $input['commit_mode'],
|
||||||
'github_access' => $input['github_access'],
|
'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null),
|
||||||
'bundle_member' => true,
|
'bundle_member' => true,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -9676,7 +9766,7 @@ class release_manager
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata = self::jsonDecode($version['metadata_json'] ?? null);
|
$metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null));
|
||||||
$commit = $this->versionGithubCommit(['metadata' => $metadata]);
|
$commit = $this->versionGithubCommit(['metadata' => $metadata]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -9699,6 +9789,26 @@ class release_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function publicReleaseVersionMetadata(mixed $metadata): array
|
||||||
|
{
|
||||||
|
if (!is_array($metadata)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($metadata['github_access']);
|
||||||
|
return $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array
|
||||||
|
{
|
||||||
|
if (!is_array($githubAccess)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']);
|
||||||
|
return $githubAccess;
|
||||||
|
}
|
||||||
|
|
||||||
private function publicAssignment(array $assignment): array
|
private function publicAssignment(array $assignment): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,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)
|
||||||
@@ -73,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.
|
||||||
@@ -120,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);
|
||||||
@@ -237,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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ use objects\selfserve_config_versions_o;
|
|||||||
|
|
||||||
class selfserve_studio_graph
|
class selfserve_studio_graph
|
||||||
{
|
{
|
||||||
|
private const DEFAULT_PATH_MAX_STATES = 2048;
|
||||||
|
private const MAX_PATH_MAX_STATES = 2048;
|
||||||
|
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
||||||
|
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
||||||
|
|
||||||
/** @var array<string,array<int,string>> */
|
/** @var array<string,array<int,string>> */
|
||||||
private array $columnCache = [];
|
private array $columnCache = [];
|
||||||
|
|
||||||
@@ -342,7 +347,7 @@ class selfserve_studio_graph
|
|||||||
if ($versioning->isV2Config($config)) {
|
if ($versioning->isV2Config($config)) {
|
||||||
foreach ($operations as $operation) {
|
foreach ($operations as $operation) {
|
||||||
if (is_array($operation)) {
|
if (is_array($operation)) {
|
||||||
$this->applyConfigOperation($departmentId, $config, $operation);
|
$this->applyConfigOperation($departmentId, $config, $operation, $permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +361,7 @@ class selfserve_studio_graph
|
|||||||
} else {
|
} else {
|
||||||
foreach ($operations as $operation) {
|
foreach ($operations as $operation) {
|
||||||
if (is_array($operation)) {
|
if (is_array($operation)) {
|
||||||
$this->applyOperation($departmentId, $operation);
|
$this->applyOperation($departmentId, $operation, $permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,21 +517,11 @@ class selfserve_studio_graph
|
|||||||
throw new \RuntimeException('lane_id is required for studio simulation.');
|
throw new \RuntimeException('lane_id is required for studio simulation.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
|
|
||||||
if (!in_array($configSource, ['draft', 'published'], true)) {
|
|
||||||
$configSource = 'draft';
|
|
||||||
}
|
|
||||||
|
|
||||||
$versioning = new selfserve_config_versioning();
|
$versioning = new selfserve_config_versioning();
|
||||||
if ($configSource === 'published') {
|
$configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
|
||||||
$version = $versioning->getPublishedV2Config($departmentId);
|
$configSource = $configContext['config_source'];
|
||||||
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
|
$config = $configContext['config'];
|
||||||
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
|
$versionId = $configContext['version_id'];
|
||||||
} else {
|
|
||||||
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
|
|
||||||
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
|
|
||||||
$versionId = isset($version['id']) ? (int)$version['id'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||||
$includeHardware = $includeHardware !== false;
|
$includeHardware = $includeHardware !== false;
|
||||||
@@ -587,6 +582,70 @@ class selfserve_studio_graph
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<string,bool> $permissions
|
||||||
|
* @return array{config_source:string,config:array<string,mixed>|null,version_id:int|null}
|
||||||
|
*/
|
||||||
|
private function loadSimulationConfig(
|
||||||
|
int $departmentId,
|
||||||
|
array $payload,
|
||||||
|
array $permissions,
|
||||||
|
selfserve_config_versioning $versioning
|
||||||
|
): array {
|
||||||
|
$configSource = $this->resolveSimulationConfigSource($payload, $permissions);
|
||||||
|
|
||||||
|
if ($configSource === 'published') {
|
||||||
|
$version = $versioning->getPublishedV2Config($departmentId);
|
||||||
|
return [
|
||||||
|
'config_source' => $configSource,
|
||||||
|
'config' => is_array($version['config'] ?? null) ? (array)$version['config'] : null,
|
||||||
|
'version_id' => isset($version['version_id']) ? (int)$version['version_id'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus(
|
||||||
|
$departmentId,
|
||||||
|
selfserve_config_versioning::STATUS_DRAFT
|
||||||
|
);
|
||||||
|
if (!$draft->exists()) {
|
||||||
|
return [
|
||||||
|
'config_source' => $configSource,
|
||||||
|
'config' => $versioning->snapshotLegacyConfig($departmentId),
|
||||||
|
'version_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = (array)($draft->config_json->value() ?? []);
|
||||||
|
if (!$versioning->isV2Config($config)) {
|
||||||
|
$config = $versioning->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'config_source' => $configSource,
|
||||||
|
'config' => $config,
|
||||||
|
'version_id' => (int)$draft->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @param array<string,bool> $permissions
|
||||||
|
*/
|
||||||
|
private function resolveSimulationConfigSource(array $payload, array $permissions): string
|
||||||
|
{
|
||||||
|
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
|
||||||
|
if (!in_array($configSource, ['draft', 'published'], true)) {
|
||||||
|
$configSource = 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configSource === 'draft' && !($permissions['can_view'] ?? false)) {
|
||||||
|
throw new \RuntimeException('Draft studio simulation requires list_department_selfserve_config_versions permission.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configSource;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $payload
|
* @param array<string,mixed> $payload
|
||||||
* @param array<string,bool> $permissions
|
* @param array<string,bool> $permissions
|
||||||
@@ -600,21 +659,11 @@ class selfserve_studio_graph
|
|||||||
?callable $progressCallback = null
|
?callable $progressCallback = null
|
||||||
): array
|
): array
|
||||||
{
|
{
|
||||||
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
|
|
||||||
if (!in_array($configSource, ['draft', 'published'], true)) {
|
|
||||||
$configSource = 'draft';
|
|
||||||
}
|
|
||||||
|
|
||||||
$versioning = new selfserve_config_versioning();
|
$versioning = new selfserve_config_versioning();
|
||||||
if ($configSource === 'published') {
|
$configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
|
||||||
$version = $versioning->getPublishedV2Config($departmentId);
|
$configSource = $configContext['config_source'];
|
||||||
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
|
$config = $configContext['config'];
|
||||||
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
|
$versionId = $configContext['version_id'];
|
||||||
} else {
|
|
||||||
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
|
|
||||||
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
|
|
||||||
$versionId = isset($version['id']) ? (int)$version['id'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||||
$includeHardware = $includeHardware !== false;
|
$includeHardware = $includeHardware !== false;
|
||||||
@@ -678,7 +727,11 @@ class selfserve_studio_graph
|
|||||||
$vehicleTypeIds[] = null;
|
$vehicleTypeIds[] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxStates = $this->pathLimit($payload['max_states'] ?? null);
|
$maxStates = $this->pathLimit(
|
||||||
|
$payload['max_states'] ?? null,
|
||||||
|
self::DEFAULT_PATH_MAX_STATES,
|
||||||
|
self::MAX_PATH_MAX_STATES
|
||||||
|
);
|
||||||
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
|
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
|
||||||
if ($reg === '') {
|
if ($reg === '') {
|
||||||
$reg = 'TEST123';
|
$reg = 'TEST123';
|
||||||
@@ -694,14 +747,18 @@ class selfserve_studio_graph
|
|||||||
$stateCount = 0;
|
$stateCount = 0;
|
||||||
$terminalPathCount = 0;
|
$terminalPathCount = 0;
|
||||||
$questionIds = [];
|
$questionIds = [];
|
||||||
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null);
|
$pathSampleLimit = $this->pathLimit(
|
||||||
|
$payload['path_sample_limit'] ?? null,
|
||||||
|
self::DEFAULT_PATH_SAMPLE_LIMIT,
|
||||||
|
self::MAX_PATH_SAMPLE_LIMIT
|
||||||
|
);
|
||||||
$paths = [];
|
$paths = [];
|
||||||
$scenarioCount = max(1, count($vehicleTypeIds));
|
$scenarioCount = max(1, count($vehicleTypeIds));
|
||||||
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
|
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
|
||||||
|
|
||||||
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
|
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
|
||||||
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
|
$remainingStates = $maxStates - $stateCount;
|
||||||
if ($remainingStates !== null && $remainingStates <= 0) {
|
if ($remainingStates <= 0) {
|
||||||
$truncated = true;
|
$truncated = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -759,7 +816,7 @@ class selfserve_studio_graph
|
|||||||
$projectionOptions = [
|
$projectionOptions = [
|
||||||
'scope' => $scenarioScope,
|
'scope' => $scenarioScope,
|
||||||
'max_states' => $remainingStates,
|
'max_states' => $remainingStates,
|
||||||
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)),
|
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
|
||||||
'progress_callback' => function (array $projection) use (
|
'progress_callback' => function (array $projection) use (
|
||||||
$progressCallback,
|
$progressCallback,
|
||||||
&$outcomes,
|
&$outcomes,
|
||||||
@@ -787,7 +844,7 @@ class selfserve_studio_graph
|
|||||||
|
|
||||||
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
|
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
|
||||||
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
|
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
|
||||||
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) {
|
if (count($partialPaths) > $pathSampleLimit) {
|
||||||
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
|
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,12 +896,6 @@ class selfserve_studio_graph
|
|||||||
},
|
},
|
||||||
'confirmation_rows' => $confirmationRows,
|
'confirmation_rows' => $confirmationRows,
|
||||||
];
|
];
|
||||||
if ($remainingStates === null) {
|
|
||||||
unset($projectionOptions['max_states']);
|
|
||||||
}
|
|
||||||
if ($pathSampleLimit === null) {
|
|
||||||
unset($projectionOptions['path_sample_limit']);
|
|
||||||
}
|
|
||||||
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
|
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
|
||||||
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
|
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
|
||||||
if (is_array($outcome)) {
|
if (is_array($outcome)) {
|
||||||
@@ -855,7 +906,7 @@ class selfserve_studio_graph
|
|||||||
if (!is_array($path)) {
|
if (!is_array($path)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
if (count($paths) < $pathSampleLimit) {
|
||||||
$paths[] = $path;
|
$paths[] = $path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -918,9 +969,18 @@ class selfserve_studio_graph
|
|||||||
*/
|
*/
|
||||||
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
|
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
|
||||||
{
|
{
|
||||||
$maxStates = $this->pathLimit($options['max_states'] ?? null);
|
$maxStates = $this->pathLimit(
|
||||||
|
$options['max_states'] ?? null,
|
||||||
|
self::DEFAULT_PATH_MAX_STATES,
|
||||||
|
self::MAX_PATH_MAX_STATES
|
||||||
|
);
|
||||||
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
|
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
|
||||||
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
|
$pathSampleLimit = $this->pathLimit(
|
||||||
|
$options['path_sample_limit'] ?? null,
|
||||||
|
self::DEFAULT_PATH_SAMPLE_LIMIT,
|
||||||
|
self::MAX_PATH_SAMPLE_LIMIT,
|
||||||
|
0
|
||||||
|
);
|
||||||
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
|
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
|
||||||
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
|
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
|
||||||
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
|
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
|
||||||
@@ -938,7 +998,7 @@ class selfserve_studio_graph
|
|||||||
$truncated = false;
|
$truncated = false;
|
||||||
|
|
||||||
while ($stack !== []) {
|
while ($stack !== []) {
|
||||||
if ($maxStates !== null && $stateCount >= $maxStates) {
|
if ($stateCount >= $maxStates) {
|
||||||
$truncated = true;
|
$truncated = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -997,7 +1057,7 @@ class selfserve_studio_graph
|
|||||||
$terminalPathCount++;
|
$terminalPathCount++;
|
||||||
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
|
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
|
||||||
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
|
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
|
||||||
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
|
if (count($paths) < $pathSampleLimit) {
|
||||||
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
|
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1676,7 +1736,7 @@ class selfserve_studio_graph
|
|||||||
* @param array<string,mixed> $config
|
* @param array<string,mixed> $config
|
||||||
* @param array<string,mixed> $operation
|
* @param array<string,mixed> $operation
|
||||||
*/
|
*/
|
||||||
private function applyConfigOperation(int $departmentId, array &$config, array $operation): void
|
private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void
|
||||||
{
|
{
|
||||||
$action = strtolower((string)($operation['action'] ?? ''));
|
$action = strtolower((string)($operation['action'] ?? ''));
|
||||||
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
|
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
|
||||||
@@ -1703,7 +1763,7 @@ class selfserve_studio_graph
|
|||||||
throw new \RuntimeException('Studio graph operation is missing entity.');
|
throw new \RuntimeException('Studio graph operation is missing entity.');
|
||||||
}
|
}
|
||||||
if ($entity === 'lane') {
|
if ($entity === 'lane') {
|
||||||
$this->applyLaneOperation($departmentId, $action, $id, $data);
|
$this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($entity === 'rule') {
|
if ($entity === 'rule') {
|
||||||
@@ -2761,7 +2821,7 @@ class selfserve_studio_graph
|
|||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $operation
|
* @param array<string,mixed> $operation
|
||||||
*/
|
*/
|
||||||
private function applyOperation(int $departmentId, array $operation): void
|
private function applyOperation(int $departmentId, array $operation, array $permissions = []): void
|
||||||
{
|
{
|
||||||
$action = strtolower((string)($operation['action'] ?? ''));
|
$action = strtolower((string)($operation['action'] ?? ''));
|
||||||
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
|
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
|
||||||
@@ -2784,7 +2844,7 @@ class selfserve_studio_graph
|
|||||||
throw new \RuntimeException('Studio graph operation is missing entity.');
|
throw new \RuntimeException('Studio graph operation is missing entity.');
|
||||||
}
|
}
|
||||||
if ($entity === 'lane') {
|
if ($entity === 'lane') {
|
||||||
$this->applyLaneOperation($departmentId, $action, $id, $data);
|
$this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2810,12 +2870,14 @@ class selfserve_studio_graph
|
|||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $data
|
* @param array<string,mixed> $data
|
||||||
*/
|
*/
|
||||||
private function applyLaneOperation(int $departmentId, string $action, int $id, array $data): void
|
private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void
|
||||||
{
|
{
|
||||||
if (!$this->tableExists('department_lanes')) {
|
if (!$this->tableExists('department_lanes')) {
|
||||||
throw new \RuntimeException('Department lanes are not available.');
|
throw new \RuntimeException('Department lanes are not available.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertLaneOperationAuthorized($action, $data, $permissions);
|
||||||
|
|
||||||
if ($action === 'create') {
|
if ($action === 'create') {
|
||||||
$this->createLane($departmentId, $data);
|
$this->createLane($departmentId, $data);
|
||||||
return;
|
return;
|
||||||
@@ -2842,6 +2904,37 @@ class selfserve_studio_graph
|
|||||||
throw new \RuntimeException('Unsupported studio lane operation: ' . $action);
|
throw new \RuntimeException('Unsupported studio lane operation: ' . $action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $data
|
||||||
|
* @param array<string,bool> $permissions
|
||||||
|
*/
|
||||||
|
private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void
|
||||||
|
{
|
||||||
|
if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) {
|
||||||
|
throw new \RuntimeException('Missing permission: add_department_lane.');
|
||||||
|
}
|
||||||
|
if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) {
|
||||||
|
throw new \RuntimeException('Missing permission: edit_department_lane.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($action, ['create', 'update'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relayFields = [
|
||||||
|
'relay_in_id',
|
||||||
|
'relay_out_id',
|
||||||
|
'relay_machine_id',
|
||||||
|
'relay_machine_program_picker_id',
|
||||||
|
'relay_machine_cleaner_id',
|
||||||
|
];
|
||||||
|
foreach ($relayFields as $field) {
|
||||||
|
if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) {
|
||||||
|
throw new \RuntimeException('Missing permission: modules_shelly_config.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $data
|
* @param array<string,mixed> $data
|
||||||
*/
|
*/
|
||||||
@@ -4240,14 +4333,18 @@ class selfserve_studio_graph
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pathLimit(mixed $value): ?int
|
private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
|
||||||
{
|
{
|
||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
return null;
|
return max($min, min($max, $default));
|
||||||
}
|
}
|
||||||
|
|
||||||
$parsed = (int)$value;
|
$parsed = (int)$value;
|
||||||
return $parsed > 0 ? $parsed : null;
|
if ($parsed < $min) {
|
||||||
|
return max($min, min($max, $default));
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($max, $parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4319,8 +4416,10 @@ class selfserve_studio_graph
|
|||||||
int $terminalPathCount,
|
int $terminalPathCount,
|
||||||
array $questionIds,
|
array $questionIds,
|
||||||
array $progress = [],
|
array $progress = [],
|
||||||
array $confirmationRows = []
|
?array $confirmationRows = null
|
||||||
): array {
|
): array {
|
||||||
|
$confirmationRows = $confirmationRows ?? [];
|
||||||
|
|
||||||
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
|
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
|
||||||
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
|
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
|
||||||
foreach ($outcomes as $index => &$outcome) {
|
foreach ($outcomes as $index => &$outcome) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -509,39 +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.");
|
||||||
$this->turnOnCleanerRelayForWashStart();
|
$previous_customer_number = $this->getCustomerNumber();
|
||||||
} catch (\Throwable $e) {
|
$previous_license_plate = $this->getLicensePlate();
|
||||||
$this->setCustomerNumber($previous_customer_number);
|
$previous_status = $this->getLaneStatus();
|
||||||
$this->setLicensePlate($previous_license_plate);
|
// Set the customer number and license plate
|
||||||
$this->setLaneState(selfserve_lane_state::IDLE);
|
$this->setCustomerNumber($customer_number);
|
||||||
throw $e;
|
$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
|
||||||
|
|||||||
@@ -223,9 +223,8 @@ trait selfserve_lane_invoice_t
|
|||||||
{
|
{
|
||||||
$billing_customer_number = $this->getCustomerNumber();
|
$billing_customer_number = $this->getCustomerNumber();
|
||||||
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
||||||
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
|
|
||||||
$order = (new orders_o())->add(
|
$order = (new orders_o())->add(
|
||||||
$order_customer_number,
|
$billing_customer_number,
|
||||||
self::INVOICE_SYSTEM_USER_ID,
|
self::INVOICE_SYSTEM_USER_ID,
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +126,7 @@ trait selfserve_lane_relay_controller_t
|
|||||||
*/
|
*/
|
||||||
public function setMachineRelayStatusHard(bool $on): bool
|
public function setMachineRelayStatusHard(bool $on): bool
|
||||||
{
|
{
|
||||||
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
|
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -467,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -320,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6239,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:
|
||||||
@@ -9330,10 +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 commands require the matching explicit command permissions.
|
||||||
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
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
|
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
|
lanes. Customer `STOP` requires the customer's active self-serve wash in the target department.
|
||||||
department.
|
|
||||||
operationId: sendSelfServeLaneCommand
|
operationId: sendSelfServeLaneCommand
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -18283,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
|
||||||
@@ -21322,5 +21352,3 @@ components:
|
|||||||
success: { type: boolean, example: true }
|
success: { type: boolean, example: true }
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
|
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -65,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
|
||||||
@@ -97,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 {
|
||||||
@@ -259,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,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
|
||||||
@@ -421,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,7 @@ 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 {
|
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||||
@@ -329,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,7 @@ 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';
|
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
@@ -123,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) {
|
||||||
@@ -457,18 +458,34 @@ 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 = $this->resolveEffectiveCustomerNumber();
|
||||||
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
|
$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:
|
||||||
@@ -476,7 +493,7 @@ class moduleSelfServeRoute
|
|||||||
$lane,
|
$lane,
|
||||||
$customer_number,
|
$customer_number,
|
||||||
'modules_selfserve_lane_command_execute_start',
|
'modules_selfserve_lane_command_execute_start',
|
||||||
false
|
true
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::STOP:
|
case selfserve_lane_command::STOP:
|
||||||
@@ -485,31 +502,26 @@ class moduleSelfServeRoute
|
|||||||
$customer_number,
|
$customer_number,
|
||||||
'modules_selfserve_lane_command_execute_stop',
|
'modules_selfserve_lane_command_execute_stop',
|
||||||
true,
|
true,
|
||||||
true
|
true,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RESERVE:
|
case selfserve_lane_command::RESERVE:
|
||||||
$this->requireSelfServeLaneCommandPermission(
|
$this->requireOperatorLaneCommandPermission(
|
||||||
$lane,
|
$lane,
|
||||||
$customer_number,
|
'modules_selfserve_lane_command_execute_reserve'
|
||||||
'modules_selfserve_lane_command_execute_reserve',
|
|
||||||
false
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RELEASE:
|
case selfserve_lane_command::RELEASE:
|
||||||
$this->requireSelfServeLaneCommandPermission(
|
$this->requireOperatorLaneCommandPermission(
|
||||||
$lane,
|
$lane,
|
||||||
$customer_number,
|
'modules_selfserve_lane_command_execute_release'
|
||||||
'modules_selfserve_lane_command_execute_release',
|
|
||||||
false
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::RESET:
|
case selfserve_lane_command::RESET:
|
||||||
$this->requireSelfServeLaneCommandPermission(
|
$this->requireOperatorLaneCommandPermission(
|
||||||
$lane,
|
$lane,
|
||||||
$customer_number,
|
'modules_selfserve_lane_command_execute_reset'
|
||||||
'modules_selfserve_lane_command_execute_reset',
|
|
||||||
false
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||||
@@ -1230,7 +1242,7 @@ class moduleSelfServeRoute
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireInProgressWashDetailsAccess(): ?int
|
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
@@ -1240,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,6 +1375,51 @@ 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
|
* @param array<int,string> $permissions
|
||||||
*/
|
*/
|
||||||
@@ -1405,7 +1467,8 @@ class moduleSelfServeRoute
|
|||||||
int $customer_number,
|
int $customer_number,
|
||||||
string $command_permission,
|
string $command_permission,
|
||||||
bool $allow_customer_self_serve,
|
bool $allow_customer_self_serve,
|
||||||
bool $requires_active_wash = false
|
bool $requires_active_wash = false,
|
||||||
|
bool $allow_department_active_wash = false
|
||||||
): void {
|
): void {
|
||||||
$elevated_permissions = [
|
$elevated_permissions = [
|
||||||
'modules_selfserve_lane_command_execute',
|
'modules_selfserve_lane_command_execute',
|
||||||
@@ -1415,9 +1478,9 @@ class moduleSelfServeRoute
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($allow_customer_self_serve) {
|
if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) {
|
||||||
$customer_allowed = $requires_active_wash
|
$customer_allowed = $requires_active_wash
|
||||||
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
||||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
|
|
||||||
if ($customer_allowed) {
|
if ($customer_allowed) {
|
||||||
@@ -1432,6 +1495,18 @@ class moduleSelfServeRoute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
{
|
{
|
||||||
$elevated_permissions = [
|
$elevated_permissions = [
|
||||||
@@ -1449,6 +1524,15 @@ class moduleSelfServeRoute
|
|||||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_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
|
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
return $customer_number > 0
|
return $customer_number > 0
|
||||||
@@ -1477,7 +1561,61 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||||
@@ -1562,7 +1700,6 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requestedShellyTransportOverride(): ?string
|
private function requestedShellyTransportOverride(): ?string
|
||||||
{
|
{
|
||||||
$transport = null;
|
$transport = null;
|
||||||
@@ -1600,6 +1737,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -368,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;
|
||||||
|
|||||||
@@ -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 !== []) {
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ 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']);
|
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
|
||||||
if (!$orderItem->exists()) {
|
if (!$orderItem->exists()) {
|
||||||
$response->error('Order item not found', 404);
|
$response->error('Order item not found', 404);
|
||||||
@@ -219,6 +220,17 @@ class orderItemsRoute
|
|||||||
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
||||||
$response->error('Notes is required for this product', 400);
|
$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)) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class subusersRoute
|
|||||||
if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) {
|
if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) {
|
||||||
$this->emitForbidden([$permission]);
|
$this->emitForbidden([$permission]);
|
||||||
}
|
}
|
||||||
|
self::requirePermission($permission);
|
||||||
return $customerNumber;
|
return $customerNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,9 +1235,11 @@ class subusersRoute
|
|||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$this->requirePermission('edit_subusers');
|
$this->requirePermission('edit_subusers');
|
||||||
self::requireParameters(['id']);
|
self::requireParameters(['id', 'customer_number']);
|
||||||
$subuserId = (int)self::getParameter('id');
|
$subuserId = (int)self::getParameter('id');
|
||||||
self::requireType($subuserId, self::type_int());
|
self::requireType($subuserId, self::type_int());
|
||||||
|
$customerNumber = (int)self::getParameter('customer_number');
|
||||||
|
self::requireType($customerNumber, self::type_int());
|
||||||
|
|
||||||
$subuser = (new subusers_o())->select($subuserId);
|
$subuser = (new subusers_o())->select($subuserId);
|
||||||
if (!$subuser->exists()) {
|
if (!$subuser->exists()) {
|
||||||
@@ -1244,21 +1247,23 @@ class subusersRoute
|
|||||||
}
|
}
|
||||||
$subuser->getObjectProperties();
|
$subuser->getObjectProperties();
|
||||||
|
|
||||||
|
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
|
||||||
|
if ($grant === null) {
|
||||||
|
$response->error('Subuser grant not found for selected customer', 404);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$subuser->requiresSetup()) {
|
if (!$subuser->requiresSetup()) {
|
||||||
$response->error('Driver account already accepted the invitation.', 409);
|
$response->error('Driver account already accepted the invitation.', 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
$invite = $this->issueSetupInvite($subuser);
|
$invite = $this->issueSetupInvite($subuser);
|
||||||
$response->success([
|
$response->success([
|
||||||
'subuser' => [
|
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
||||||
'id' => (int)$subuser->id,
|
'grant' => $grant->asArray(),
|
||||||
'setup_required' => true,
|
|
||||||
'can_resend_invite' => true,
|
|
||||||
],
|
|
||||||
'invite' => $invite,
|
'invite' => $invite,
|
||||||
]);
|
]);
|
||||||
}, [
|
}, [
|
||||||
'edit_subusers' => 'Resend chauffeur invites for any customer (superuser).',
|
'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/subusers/invite/resend', function () {
|
$this->post('/subusers/invite/resend', function () {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class systemSearchRoute
|
|||||||
'allowed_types' => $allowedTypes,
|
'allowed_types' => $allowedTypes,
|
||||||
'own_only_types' => $ownOnlyTypes,
|
'own_only_types' => $ownOnlyTypes,
|
||||||
'own_customer_number' => $this->resolveEffectiveCustomerNumber(),
|
'own_customer_number' => $this->resolveEffectiveCustomerNumber(),
|
||||||
|
'allowed_department_ids' => $this->resolveAllowedDepartmentIds($user),
|
||||||
'permissions_catalog_all' => $permissionsCatalogAll,
|
'permissions_catalog_all' => $permissionsCatalogAll,
|
||||||
'permissions_catalog_own' => $permissionsCatalogOwn,
|
'permissions_catalog_own' => $permissionsCatalogOwn,
|
||||||
'module_config_visibility' => $this->buildModuleConfigVisibility(),
|
'module_config_visibility' => $this->buildModuleConfigVisibility(),
|
||||||
@@ -180,7 +181,7 @@ class systemSearchRoute
|
|||||||
'own' => [],
|
'own' => [],
|
||||||
],
|
],
|
||||||
'users' => [
|
'users' => [
|
||||||
'all' => ['list_users', 'get_user', 'get_user_id', 'get_user_name'],
|
'all' => ['list_users', 'get_user'],
|
||||||
'own' => ['user'],
|
'own' => ['user'],
|
||||||
],
|
],
|
||||||
'subusers' => [
|
'subusers' => [
|
||||||
@@ -507,6 +508,27 @@ class systemSearchRoute
|
|||||||
return $flat;
|
return $flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $user
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function resolveAllowedDepartmentIds(mixed $user): array
|
||||||
|
{
|
||||||
|
if ($user === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$departments = $user->getGroup()->getDepartments();
|
||||||
|
if (!is_array($departments)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return array_values(array_unique(array_map('intval', $departments)));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function parseTypeList(mixed $value): array
|
private function parseTypeList(mixed $value): array
|
||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|||||||
@@ -105,8 +105,13 @@ class usersRoute
|
|||||||
if (!isset($data['role'])) {
|
if (!isset($data['role'])) {
|
||||||
$response->error('Role is required', 400);
|
$response->error('Role is required', 400);
|
||||||
}
|
}
|
||||||
|
$role = (int)$data['role'];
|
||||||
|
// Creating users with elevated roles requires the same permission as role edits
|
||||||
|
if ($role !== 0) {
|
||||||
|
$this->requirePermission('edit_user_role');
|
||||||
|
}
|
||||||
// Add the user
|
// Add the user
|
||||||
(new users_o())->add($data['customer_number'], $data['password'], (int)$data['role']);
|
(new users_o())->add($data['customer_number'], $data['password'], $role);
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
|
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
|
||||||
// Return a success message
|
// Return a success message
|
||||||
@@ -202,4 +207,4 @@ class usersRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class xlvaskUsageLogsRoute
|
|||||||
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
|
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
|
||||||
->listObjectsWithPaginationIfSet(
|
->listObjectsWithPaginationIfSet(
|
||||||
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
||||||
$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);
|
$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);
|
||||||
// Remove the 'id' field from the log
|
// Remove the 'id' field from the log
|
||||||
$id = (int)$log['id'];
|
$id = (int)$log['id'];
|
||||||
$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log);
|
$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log);
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"generated_at": "2026-05-19T00:57:20+02:00",
|
|
||||||
"active": {
|
|
||||||
"database": {
|
|
||||||
"id": 1,
|
|
||||||
"host": "23.88.23.183",
|
|
||||||
"port": 5432,
|
|
||||||
"database": "nnks_db",
|
|
||||||
"user": "root",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6ImRBSDRkejROdGNKTm5NNHQiLCJ0YWciOiJFRFNCMmlNRWI3eXZXUDdvT1FIZnB3PT0iLCJjaXBoZXJ0ZXh0IjoiNEpSUGJnSjExVmV1cWIrV3ByZDV0b0l0NlE9PSJ9",
|
|
||||||
"ssl_mode": "DISABLED"
|
|
||||||
},
|
|
||||||
"redis": {
|
|
||||||
"id": 2,
|
|
||||||
"host": "23.88.23.183",
|
|
||||||
"port": 5433,
|
|
||||||
"database": 0,
|
|
||||||
"user": "default",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6Ii9PVTB0bExoend6RWlPRGoiLCJ0YWciOiIxVGRyWEFlVkV4NnpieHIxQVBrcGR3PT0iLCJjaXBoZXJ0ZXh0IjoiZG02ekVoRjI4blZjUWFuN0R5UWRxYWExY0E9PSJ9"
|
|
||||||
},
|
|
||||||
"minio": {
|
|
||||||
"id": 4,
|
|
||||||
"endpoint": "http://162.55.225.220:9000",
|
|
||||||
"access_key": "d7u6RaFyYmckAIWYGUYr",
|
|
||||||
"secret_key_secret": "twsec:v1:eyJub25jZSI6Ikx5SjdUWTZqVTIrV0lWK1UiLCJ0YWciOiI5dkF5MVB1MWRwcEZNU3NzVVNETUZRPT0iLCJjaXBoZXJ0ZXh0IjoiUkRCQldZdkE3SksxWG1CMWN2TnZpUnFiSXY5ckNuL3Raanc5K3lIbGwzQkJlbTBLOUV1c0ZRPT0ifQ==",
|
|
||||||
"buckets": [
|
|
||||||
"attachments",
|
|
||||||
"backups",
|
|
||||||
"invoices",
|
|
||||||
"pdfs",
|
|
||||||
"uploads",
|
|
||||||
"truckwashdev"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"failover": {
|
|
||||||
"config": {
|
|
||||||
"enabled": true,
|
|
||||||
"database_enabled": true,
|
|
||||||
"redis_enabled": false,
|
|
||||||
"minio_enabled": true,
|
|
||||||
"max_status_age_seconds": 90
|
|
||||||
},
|
|
||||||
"hosts": {
|
|
||||||
"database": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"kind": "database",
|
|
||||||
"label": "Current database primary",
|
|
||||||
"host": "23.88.23.183",
|
|
||||||
"port": 5432,
|
|
||||||
"database_name": "nnks_db",
|
|
||||||
"database_index": null,
|
|
||||||
"username": "root",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6ImRBSDRkejROdGNKTm5NNHQiLCJ0YWciOiJFRFNCMmlNRWI3eXZXUDdvT1FIZnB3PT0iLCJjaXBoZXJ0ZXh0IjoiNEpSUGJnSjExVmV1cWIrV3ByZDV0b0l0NlE9PSJ9",
|
|
||||||
"admin_username": "",
|
|
||||||
"admin_password_secret": "",
|
|
||||||
"replication_username": "",
|
|
||||||
"replication_password_secret": "",
|
|
||||||
"role": "primary",
|
|
||||||
"status": "ok",
|
|
||||||
"replication_source_id": null,
|
|
||||||
"ssl_mode": "DISABLED",
|
|
||||||
"options_json": "{\"source\":\"environment\"}",
|
|
||||||
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-1-1114923\",\"gtid_current_pos\":\"0-1-1114923\",\"gtid_slave_pos\":\"\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"OFF\",\"server_id\":\"1\",\"clone_plugin_active\":false},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:17",
|
|
||||||
"updated_at": "2026-05-18 22:57:17",
|
|
||||||
"deleted_at": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"kind": "database",
|
|
||||||
"label": "mariadb-replica-1",
|
|
||||||
"host": "65.21.214.30",
|
|
||||||
"port": 5441,
|
|
||||||
"database_name": "nnks_db",
|
|
||||||
"database_index": null,
|
|
||||||
"username": "nnks_db_user",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6IkFzazZBNCsxWFh0L3dUWDEiLCJ0YWciOiJ2WVhNNXhteGpZZ0NHeXEzb0hILzBRPT0iLCJjaXBoZXJ0ZXh0IjoiNjVNaUVwRFVNeDMrdmtUZ2lqelhtbkJnK05HV0p3dlpyUFFkWWhKazdBMD0ifQ==",
|
|
||||||
"admin_username": "root",
|
|
||||||
"admin_password_secret": "twsec:v1:eyJub25jZSI6IjVRNkVIdGNEV2JhZXVEc04iLCJ0YWciOiJRZitRUHhQYzM1NE1jVVpYbGFmbzBBPT0iLCJjaXBoZXJ0ZXh0IjoiZXAzbzJuQW0vMldHSkFHSXhGWmM4SkFyeFVnNWt1K1JrcmpxN3gxZUtnYz0ifQ==",
|
|
||||||
"replication_username": "replication",
|
|
||||||
"replication_password_secret": "twsec:v1:eyJub25jZSI6IlJUYW1pUHQ2UjhZTFlIUloiLCJ0YWciOiJDaVI5Y251eUVkRHo2RmorYkp3N2NRPT0iLCJjaXBoZXJ0ZXh0IjoiREp5WTRTSS9qVXcyOEFGdVJBTE5BdzVnY2ROVFNiNmR2NkQ5Y1M3RkJSZz0ifQ==",
|
|
||||||
"role": "replica",
|
|
||||||
"status": "ok",
|
|
||||||
"replication_source_id": 1,
|
|
||||||
"ssl_mode": "DISABLED",
|
|
||||||
"options_json": "{\"allow_preseeded_replica\":true}",
|
|
||||||
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":0,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-2-1098\",\"gtid_current_pos\":\"0-1-1114860\",\"gtid_slave_pos\":\"0-1-1114860\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"ON\",\"server_id\":\"2\",\"clone_plugin_active\":false,\"source_gtid_executed\":\"0-1-1114955\",\"replica_status\":{\"Slave_IO_State\":\"Waiting for master to send event\",\"Master_Host\":\"23.88.23.183\",\"Master_User\":\"replication\",\"Master_Port\":\"5432\",\"Connect_Retry\":\"60\",\"Master_Log_File\":\"mariadb-bin.000017\",\"Read_Master_Log_Pos\":\"237616337\",\"Relay_Log_File\":\"mysqld-relay-bin.000002\",\"Relay_Log_Pos\":\"97422\",\"Relay_Master_Log_File\":\"mariadb-bin.000017\",\"Slave_IO_Running\":\"Yes\",\"Slave_SQL_Running\":\"Yes\",\"Replicate_Do_DB\":\"\",\"Replicate_Ignore_DB\":\"\",\"Replicate_Do_Table\":\"\",\"Replicate_Ignore_Table\":\"nnks_db.edge_gateway_shell_sessions,nnks_db.logs,nnks_db.edge_gateway_log_entries,nnks_db.replication_status_snapshots,nnks_db.system_search_documents,nnks_db.replication_operations,nnks_db.replication_audit_logs,nnks_db.edge_gateway_audit_logs\",\"Replicate_Wild_Do_Table\":\"\",\"Replicate_Wild_Ignore_Table\":\"\",\"Last_Errno\":\"0\",\"Last_Error\":\"\",\"Skip_Counter\":\"0\",\"Exec_Master_Log_Pos\":\"237616337\",\"Relay_Log_Space\":\"97732\",\"Until_Condition\":\"None\",\"Until_Log_File\":\"\",\"Until_Log_Pos\":\"0\",\"Master_SSL_Allowed\":\"Yes\",\"Master_SSL_CA_File\":\"\",\"Master_SSL_CA_Path\":\"\",\"Master_SSL_Cert\":\"\",\"Master_SSL_Cipher\":\"\",\"Master_SSL_Key\":\"\",\"Seconds_Behind_Master\":\"0\",\"Master_SSL_Verify_Server_Cert\":\"Yes\",\"Last_IO_Errno\":\"0\",\"Last_IO_Error\":\"\",\"Last_SQL_Errno\":\"0\",\"Last_SQL_Error\":\"\",\"Replicate_Ignore_Server_Ids\":\"\",\"Master_Server_Id\":\"1\",\"Master_SSL_Crl\":\"\",\"Master_SSL_Crlpath\":\"\",\"Using_Gtid\":\"Slave_Pos\",\"Gtid_IO_Pos\":\"0-1-1114955\",\"Replicate_Do_Domain_Ids\":\"\",\"Replicate_Ignore_Domain_Ids\":\"\",\"Parallel_Mode\":\"optimistic\",\"SQL_Delay\":\"0\",\"SQL_Remaining_Delay\":null,\"Slave_SQL_Running_State\":\"Slave has read all relay log; waiting for more updates\",\"Slave_DDL_Groups\":\"180\",\"Slave_Non_Transactional_Groups\":\"15\",\"Slave_Transactional_Groups\":\"900658\",\"Replicate_Rewrite_DB\":\"\"}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:18",
|
|
||||||
"updated_at": "2026-05-18 22:57:18",
|
|
||||||
"deleted_at": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"kind": "database",
|
|
||||||
"label": "mariadb-replica-2",
|
|
||||||
"host": "65.21.214.30",
|
|
||||||
"port": 3307,
|
|
||||||
"database_name": "nnks_db",
|
|
||||||
"database_index": null,
|
|
||||||
"username": "nnks_db_user",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6IitoVThzV0UxVEEycDhZYkYiLCJ0YWciOiJwZSs4Z2g1QWxKNzdwb3F4c1pQelV3PT0iLCJjaXBoZXJ0ZXh0IjoiUXdFWnhMUGx1ejczbHc0aUpxc2xaeUExOFlRcWwralhGWkdtSWE2TzVHMD0ifQ==",
|
|
||||||
"admin_username": "root",
|
|
||||||
"admin_password_secret": "twsec:v1:eyJub25jZSI6Ik9BbElsV0RZSUZpUHRsZmQiLCJ0YWciOiJNczZzWFBueUVneUVNRUh1RGlSQjZnPT0iLCJjaXBoZXJ0ZXh0IjoiUW52Mmh2MUJOQXdIbm12eDg1MnUyeFRsOW9xTUxpOXI4Y1pldXV1bEdEaz0ifQ==",
|
|
||||||
"replication_username": "replication",
|
|
||||||
"replication_password_secret": "twsec:v1:eyJub25jZSI6IlJ5aTBVbCtUODFDRVArTWsiLCJ0YWciOiJKMVZ6MVZpQ2lRZU04SkpLYmlKa3hRPT0iLCJjaXBoZXJ0ZXh0IjoiajJTQUl6OW40T0xYTFZXdjhSeHlVWGJSZ2M0ZzY1R0U4ZWdIRHN4UHo5dz0ifQ==",
|
|
||||||
"role": "replica",
|
|
||||||
"status": "ok",
|
|
||||||
"replication_source_id": 1,
|
|
||||||
"ssl_mode": "DISABLED",
|
|
||||||
"options_json": "{\"allow_preseeded_replica\":true,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"coolify_target_id\":1}",
|
|
||||||
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":0,\"blockers\":[],\"raw\":{\"server_version\":\"11.8.6-MariaDB-ubu2404-log\",\"gtid_binlog_pos\":\"0-2-1098\",\"gtid_current_pos\":\"0-1-1114955\",\"gtid_slave_pos\":\"0-1-1114955\",\"gtid_strict_mode\":\"ON\",\"log_bin\":\"ON\",\"read_only\":\"ON\",\"server_id\":\"2\",\"clone_plugin_active\":false,\"source_gtid_executed\":\"0-1-1114989\",\"replica_status\":{\"Slave_IO_State\":\"Waiting for master to send event\",\"Master_Host\":\"23.88.23.183\",\"Master_User\":\"replication\",\"Master_Port\":\"5432\",\"Connect_Retry\":\"60\",\"Master_Log_File\":\"\",\"Read_Master_Log_Pos\":\"4\",\"Relay_Log_File\":\"mysqld-relay-bin.000001\",\"Relay_Log_Pos\":\"4\",\"Relay_Master_Log_File\":\"\",\"Slave_IO_Running\":\"Yes\",\"Slave_SQL_Running\":\"Yes\",\"Replicate_Do_DB\":\"\",\"Replicate_Ignore_DB\":\"\",\"Replicate_Do_Table\":\"\",\"Replicate_Ignore_Table\":\"nnks_db.edge_gateway_shell_sessions,nnks_db.logs,nnks_db.replication_status_snapshots,nnks_db.edge_gateway_log_entries,nnks_db.edge_gateway_audit_logs,nnks_db.replication_operations,nnks_db.replication_audit_logs,nnks_db.system_search_documents\",\"Replicate_Wild_Do_Table\":\"\",\"Replicate_Wild_Ignore_Table\":\"\",\"Last_Errno\":\"0\",\"Last_Error\":\"\",\"Skip_Counter\":\"0\",\"Exec_Master_Log_Pos\":\"4\",\"Relay_Log_Space\":\"256\",\"Until_Condition\":\"None\",\"Until_Log_File\":\"\",\"Until_Log_Pos\":\"0\",\"Master_SSL_Allowed\":\"Yes\",\"Master_SSL_CA_File\":\"\",\"Master_SSL_CA_Path\":\"\",\"Master_SSL_Cert\":\"\",\"Master_SSL_Cipher\":\"\",\"Master_SSL_Key\":\"\",\"Seconds_Behind_Master\":\"0\",\"Master_SSL_Verify_Server_Cert\":\"Yes\",\"Last_IO_Errno\":\"0\",\"Last_IO_Error\":\"\",\"Last_SQL_Errno\":\"0\",\"Last_SQL_Error\":\"\",\"Replicate_Ignore_Server_Ids\":\"\",\"Master_Server_Id\":\"1\",\"Master_SSL_Crl\":\"\",\"Master_SSL_Crlpath\":\"\",\"Using_Gtid\":\"Slave_Pos\",\"Gtid_IO_Pos\":\"0-1-1114955\",\"Replicate_Do_Domain_Ids\":\"\",\"Replicate_Ignore_Domain_Ids\":\"\",\"Parallel_Mode\":\"optimistic\",\"SQL_Delay\":\"0\",\"SQL_Remaining_Delay\":null,\"Slave_SQL_Running_State\":\"Slave has read all relay log; waiting for more updates\",\"Slave_DDL_Groups\":\"171\",\"Slave_Non_Transactional_Groups\":\"14\",\"Slave_Transactional_Groups\":\"114109\",\"Replicate_Rewrite_DB\":\"\"},\"replication_access_repair\":{\"ok\":true,\"denied_hosts\":[\"10.0.1.13\"],\"grant_hosts\":[\"%\",\"65.21.214.30\",\"65.21.214.%\",\"10.0.1.13\",\"10.0.1.%\"]}},\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:20",
|
|
||||||
"updated_at": "2026-05-18 22:57:20",
|
|
||||||
"deleted_at": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"redis": [
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"kind": "redis",
|
|
||||||
"label": "Current Redis primary",
|
|
||||||
"host": "23.88.23.183",
|
|
||||||
"port": 5433,
|
|
||||||
"database_name": null,
|
|
||||||
"database_index": 0,
|
|
||||||
"username": "default",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6Ii9PVTB0bExoend6RWlPRGoiLCJ0YWciOiIxVGRyWEFlVkV4NnpieHIxQVBrcGR3PT0iLCJjaXBoZXJ0ZXh0IjoiZG02ekVoRjI4blZjUWFuN0R5UWRxYWExY0E9PSJ9",
|
|
||||||
"admin_username": "",
|
|
||||||
"admin_password_secret": "",
|
|
||||||
"replication_username": "",
|
|
||||||
"replication_password_secret": "",
|
|
||||||
"role": "primary",
|
|
||||||
"status": "ok",
|
|
||||||
"replication_source_id": null,
|
|
||||||
"ssl_mode": null,
|
|
||||||
"options_json": "{\"source\":\"environment\"}",
|
|
||||||
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"role\":[\"master\",158560415,[]],\"replication\":{\"role\":\"master\",\"connected_slaves\":\"0\",\"master_failover_state\":\"no-failover\",\"master_replid\":\"d66ae5e486a819a693d4dc9cd12883ed8f987c74\",\"master_replid2\":\"0000000000000000000000000000000000000000\",\"master_repl_offset\":\"158560415\",\"second_repl_offset\":\"-1\",\"repl_backlog_active\":\"0\",\"repl_backlog_size\":\"1048576\",\"repl_backlog_first_byte_offset\":\"0\",\"repl_backlog_histlen\":\"0\"}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:17",
|
|
||||||
"updated_at": "2026-05-18 22:57:17",
|
|
||||||
"deleted_at": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"minio": [
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"kind": "minio",
|
|
||||||
"label": "Current MinIO primary",
|
|
||||||
"host": "162.55.225.220",
|
|
||||||
"port": 9000,
|
|
||||||
"database_name": null,
|
|
||||||
"database_index": null,
|
|
||||||
"username": "d7u6RaFyYmckAIWYGUYr",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6Ikx5SjdUWTZqVTIrV0lWK1UiLCJ0YWciOiI5dkF5MVB1MWRwcEZNU3NzVVNETUZRPT0iLCJjaXBoZXJ0ZXh0IjoiUkRCQldZdkE3SksxWG1CMWN2TnZpUnFiSXY5ckNuL3Raanc5K3lIbGwzQkJlbTBLOUV1c0ZRPT0ifQ==",
|
|
||||||
"admin_username": "",
|
|
||||||
"admin_password_secret": "",
|
|
||||||
"replication_username": "",
|
|
||||||
"replication_password_secret": "",
|
|
||||||
"role": "primary",
|
|
||||||
"status": "ok",
|
|
||||||
"replication_source_id": null,
|
|
||||||
"ssl_mode": null,
|
|
||||||
"options_json": "{\"source\":\"environment\",\"scheme\":\"http\",\"endpoint\":\"http://162.55.225.220:9000\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9001,\"space_headroom_percent\":20}",
|
|
||||||
"last_status_json": "{\"status\":\"ok\",\"replication_percent\":100,\"lag_seconds\":null,\"blockers\":[],\"raw\":{\"buckets\":[{\"name\":\"attachments\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"backups\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"invoices\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"pdfs\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"uploads\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null},{\"name\":\"truckwashdev\",\"status\":\"ok\",\"bytes\":null,\"objects\":null,\"expired_bytes\":null,\"expired_objects\":null,\"measured\":false,\"retention_days\":null}],\"storage\":{\"source_bytes\":0,\"source_objects\":0,\"measured\":false}},\"checked_at\":\"2026-05-19T00:57:17+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:17",
|
|
||||||
"updated_at": "2026-05-18 22:57:17",
|
|
||||||
"deleted_at": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"kind": "minio",
|
|
||||||
"label": "minio-replica-2",
|
|
||||||
"host": "94.130.142.41",
|
|
||||||
"port": 9010,
|
|
||||||
"database_name": null,
|
|
||||||
"database_index": null,
|
|
||||||
"username": "twminio38e30e538cf07101a9db00ae",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6Ild2eUxvK2ZtdjFNeTQ4bm4iLCJ0YWciOiI5M24rRDM2UnBRaUQvQUkxaDhkSTBBPT0iLCJjaXBoZXJ0ZXh0IjoiM0l1a2dvaEQvS0MyQmZLY0JHVG5wWDlkWWNIUklRK0svUjBodTJ5NEtyTT0ifQ==",
|
|
||||||
"admin_username": "",
|
|
||||||
"admin_password_secret": "twsec:v1:eyJub25jZSI6ImRiblNwSEJJWHNXeWtWMXciLCJ0YWciOiI3dTcxZ0FYN1lMclNkWFgwUzNMRHV3PT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
|
|
||||||
"replication_username": "",
|
|
||||||
"replication_password_secret": "twsec:v1:eyJub25jZSI6IkZlQ29vMDY3WVFIb1ZjWDAiLCJ0YWciOiJqN3J5RytlSGs0T2s1Skhyc2gxSW53PT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
|
|
||||||
"role": "replica",
|
|
||||||
"status": "down",
|
|
||||||
"replication_source_id": 4,
|
|
||||||
"ssl_mode": "DISABLED",
|
|
||||||
"options_json": "{\"scheme\":\"http\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9011,\"space_headroom_percent\":20,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"endpoint\":\"http://94.130.142.41:9010\",\"coolify_target_id\":2}",
|
|
||||||
"last_status_json": "{\"status\":\"down\",\"replication_percent\":0,\"lag_seconds\":null,\"blockers\":[\"Class \\\"Aws\\\\S3\\\\S3Client\\\" not found\"],\"raw\":[],\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:21",
|
|
||||||
"updated_at": "2026-05-18 22:57:21",
|
|
||||||
"deleted_at": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 13,
|
|
||||||
"kind": "minio",
|
|
||||||
"label": "minio-replica-1",
|
|
||||||
"host": "23.88.23.183",
|
|
||||||
"port": 9010,
|
|
||||||
"database_name": null,
|
|
||||||
"database_index": null,
|
|
||||||
"username": "twminio1a6370c14ab3a949a07a9e16",
|
|
||||||
"password_secret": "twsec:v1:eyJub25jZSI6ImFzK0tLZlBRYWlERzU5OWQiLCJ0YWciOiJISmtJZFpwaG5JbktnZkxHZ09DcEVBPT0iLCJjaXBoZXJ0ZXh0IjoiWTkxYm1SZ2JXWEJpdGJpeWtUUTAvMHZjSU14Rzg3a2VrcHpNSDk5UzYxRT0ifQ==",
|
|
||||||
"admin_username": "",
|
|
||||||
"admin_password_secret": "twsec:v1:eyJub25jZSI6IjZLRm5ETlZUaDNwUU0xZHQiLCJ0YWciOiJnNnB1QmNHbWliMEpuUVA2VFpGcE9nPT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
|
|
||||||
"replication_username": "",
|
|
||||||
"replication_password_secret": "twsec:v1:eyJub25jZSI6Ik5yOU1SN1gybC9pdGVheXYiLCJ0YWciOiJMbk5vWjZmNjRLMVpRemlleHNKVFZRPT0iLCJjaXBoZXJ0ZXh0IjoiIn0=",
|
|
||||||
"role": "replica",
|
|
||||||
"status": "down",
|
|
||||||
"replication_source_id": 4,
|
|
||||||
"ssl_mode": "DISABLED",
|
|
||||||
"options_json": "{\"scheme\":\"http\",\"buckets\":[\"attachments\",\"backups\",\"invoices\",\"pdfs\",\"uploads\",\"truckwashdev\"],\"console_port\":9011,\"replication_transfer_limit\":\"25Mi\",\"space_headroom_percent\":20,\"deployment_provider\":\"coolify\",\"coolify_instance_id\":1,\"endpoint\":\"http://23.88.23.183:9010\",\"coolify_target_id\":6}",
|
|
||||||
"last_status_json": "{\"status\":\"down\",\"replication_percent\":0,\"lag_seconds\":null,\"blockers\":[\"Class \\\"Aws\\\\S3\\\\S3Client\\\" not found\"],\"raw\":[],\"checked_at\":\"2026-05-19T00:57:20+02:00\"}",
|
|
||||||
"last_checked_at": "2026-05-18 22:57:21",
|
|
||||||
"updated_at": "2026-05-18 22:57:21",
|
|
||||||
"deleted_at": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -131,10 +131,10 @@ it('rejects invalid auth session tokens', function (): void {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertStatus(500)
|
->assertStatus(401)
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessageContains('Token not found');
|
->assertMessage('Invalid token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs out and invalidates the token for future session calls', function (): void {
|
it('logs out and invalidates the token for future session calls', function (): void {
|
||||||
@@ -161,10 +161,10 @@ it('logs out and invalidates the token for future session calls', function (): v
|
|||||||
$followUpSession = api_client()->get('/auth/session', $session['headers']);
|
$followUpSession = api_client()->get('/auth/session', $session['headers']);
|
||||||
|
|
||||||
$followUpSession
|
$followUpSession
|
||||||
->assertStatus(500)
|
->assertStatus(401)
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessageContains('Token not found');
|
->assertMessage('Invalid token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs out and invalidates cached subuser sessions', function (): void {
|
it('logs out and invalidates cached subuser sessions', function (): void {
|
||||||
@@ -208,8 +208,8 @@ it('rejects invalid logout tokens', function (): void {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertStatus(500)
|
->assertStatus(401)
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessageContains('Token not found');
|
->assertMessage('Invalid token');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ it('does not allow regular department listings to reveal archived departments th
|
|||||||
expect($departmentIds)
|
expect($departmentIds)
|
||||||
->toContain($activeDepartment['id'])
|
->toContain($activeDepartment['id'])
|
||||||
->not->toContain($archivedDepartment['id']);
|
->not->toContain($archivedDepartment['id']);
|
||||||
|
|
||||||
|
$response = api_client()->get('/departments?filters[name]=NOT%20NULL%2Carchived:1', $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$departmentIds = array_map(
|
||||||
|
static fn(array $department): int => (int)($department['id'] ?? 0),
|
||||||
|
is_array($response->data()) ? $response->data() : []
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($departmentIds)->not->toContain($archivedDepartment['id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects department listing when the permission is missing', function (): void {
|
it('rejects department listing when the permission is missing', function (): void {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ it('stores broker settings in edge gateway module config and uses them for shell
|
|||||||
'broker_url' => 'http://edge-broker.internal:4300',
|
'broker_url' => 'http://edge-broker.internal:4300',
|
||||||
'public_broker_url' => 'https://broker.example.test/edge-broker',
|
'public_broker_url' => 'https://broker.example.test/edge-broker',
|
||||||
'broker_auth_mode' => 'manager',
|
'broker_auth_mode' => 'manager',
|
||||||
'broker_shared_secret' => 'module-config-secret',
|
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
|
|
||||||
$update
|
$update
|
||||||
@@ -54,7 +53,7 @@ it('stores broker settings in edge gateway module config and uses them for shell
|
|||||||
->toHaveKey('broker_url', 'http://edge-broker.internal:4300')
|
->toHaveKey('broker_url', 'http://edge-broker.internal:4300')
|
||||||
->toHaveKey('public_broker_url', 'https://broker.example.test/edge-broker')
|
->toHaveKey('public_broker_url', 'https://broker.example.test/edge-broker')
|
||||||
->toHaveKey('broker_auth_mode', 'manager')
|
->toHaveKey('broker_auth_mode', 'manager')
|
||||||
->toHaveKey('broker_shared_secret', 'module-config-secret');
|
->toHaveKey('broker_shared_secret', '');
|
||||||
|
|
||||||
$presence = api_client()->post(
|
$presence = api_client()->post(
|
||||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
|
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
|
||||||
@@ -62,7 +61,7 @@ it('stores broker settings in edge gateway module config and uses them for shell
|
|||||||
'status' => 'connected',
|
'status' => 'connected',
|
||||||
'connection_id' => 'module-config-broker-presence',
|
'connection_id' => 'module-config-broker-presence',
|
||||||
],
|
],
|
||||||
edge_test_broker_headers(['X-Edge-Broker-Secret' => 'module-config-secret'])
|
edge_test_broker_headers(['X-Edge-Broker-Secret' => edge_test_broker_secret()])
|
||||||
);
|
);
|
||||||
|
|
||||||
$presence
|
$presence
|
||||||
@@ -104,8 +103,8 @@ it('returns broker diagnostics for the current edge gateway module config values
|
|||||||
|
|
||||||
$response = api_client()->post('/edgegateway/config/broker-diagnostics', [
|
$response = api_client()->post('/edgegateway/config/broker-diagnostics', [
|
||||||
'target' => 'all',
|
'target' => 'all',
|
||||||
'broker_url' => 'http://127.0.0.1:1',
|
'broker_url' => 'http://127.0.0.1:18080/metadata',
|
||||||
'public_broker_url' => 'http://127.0.0.1:1/edge-broker',
|
'public_broker_url' => 'http://127.0.0.1:18081/metadata',
|
||||||
'broker_auth_mode' => 'manager',
|
'broker_auth_mode' => 'manager',
|
||||||
'broker_shared_secret' => 'diagnostic-secret',
|
'broker_shared_secret' => 'diagnostic-secret',
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
@@ -121,6 +120,10 @@ it('returns broker diagnostics for the current edge gateway module config values
|
|||||||
->toHaveKey('broker_shared_secret')
|
->toHaveKey('broker_shared_secret')
|
||||||
->toHaveKey('broker_auth_mode', 'manager')
|
->toHaveKey('broker_auth_mode', 'manager')
|
||||||
->toHaveKey('broker_shared_secret_configured', true)
|
->toHaveKey('broker_shared_secret_configured', true)
|
||||||
|
->and($response->data()['internal_broker_connection']['url'] ?? null)
|
||||||
|
->toBe('http://127.0.0.1:1')
|
||||||
|
->and($response->data()['public_broker_url']['url'] ?? null)
|
||||||
|
->toBe('http://127.0.0.1:1/edge-broker')
|
||||||
->and($response->data()['internal_broker_connection']['ok'] ?? null)
|
->and($response->data()['internal_broker_connection']['ok'] ?? null)
|
||||||
->toBeFalse()
|
->toBeFalse()
|
||||||
->and($response->data()['public_broker_url']['ok'] ?? null)
|
->and($response->data()['public_broker_url']['ok'] ?? null)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
putenv('EMAIL_FAKE_MODE=1');
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
it('allows department admins to resend order booking confirmations', function (): void {
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Resend Booking Confirmation Customer',
|
||||||
|
'email' => 'resend-booking-confirmation@example.test',
|
||||||
|
]);
|
||||||
|
$branding = api_fixtures()->createBranding([
|
||||||
|
'name' => 'Resend Booking Confirmation Brand',
|
||||||
|
'address' => 'Resend Booking Confirmation Address 1',
|
||||||
|
]);
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Resend Booking Confirmation Department',
|
||||||
|
'branding' => $branding['id'],
|
||||||
|
]);
|
||||||
|
$booking = api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'reference' => 'RESEND-CONFIRMATION',
|
||||||
|
'reg_1' => 'RESEND1',
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'resend_booking_confirmations',
|
||||||
|
'department_access_' . $department['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order-bookings/booking-confirmation/resend', [
|
||||||
|
'id' => $booking['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
expect($response->data())
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('message', 'Booking confirmation resent successfully.')
|
||||||
|
->toHaveKey('booking')
|
||||||
|
->and($response->data()['booking'])
|
||||||
|
->toBeArray()
|
||||||
|
->and($response->data()['booking']['id'] ?? null)
|
||||||
|
->toBe($booking['id'])
|
||||||
|
->and($response->data()['booking']['reference'] ?? null)
|
||||||
|
->toBe('RESEND-CONFIRMATION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires department access when resending order booking confirmations', function (): void {
|
||||||
|
$customer = api_fixtures()->createUser([
|
||||||
|
'display_name' => 'Resend Booking Confirmation Foreign Customer',
|
||||||
|
]);
|
||||||
|
$department = api_fixtures()->createDepartment([
|
||||||
|
'name' => 'Resend Booking Confirmation Foreign Department',
|
||||||
|
]);
|
||||||
|
$booking = api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'resend_booking_confirmations',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/order-bookings/booking-confirmation/resend', [
|
||||||
|
'id' => $booking['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMissingPermissions(['department_access_' . $department['id']]);
|
||||||
|
});
|
||||||
@@ -102,6 +102,132 @@ it('creates orders through the orders endpoint', function (): void {
|
|||||||
api_fixtures()->cleanupDeleteById('orders', $orderId);
|
api_fixtures()->cleanupDeleteById('orders', $orderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('defaults order PO only from a matching active booking', function (): void {
|
||||||
|
api_test_covers('POST /orders', 'security');
|
||||||
|
api_test_covers('PUT /orders', 'security');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Order Booking PO Department']);
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Order Booking PO Other Department']);
|
||||||
|
$customer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Customer']);
|
||||||
|
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Other Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Order Booking PO Cashier']);
|
||||||
|
$matchingBooking = api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'po' => 'MATCHING-BOOKING-PO',
|
||||||
|
]);
|
||||||
|
$foreignBooking = api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $otherCustomer['customer_number'],
|
||||||
|
'department' => $otherDepartment['id'],
|
||||||
|
'po' => 'FOREIGN-BOOKING-PO',
|
||||||
|
]);
|
||||||
|
$deletedBooking = api_fixtures()->createOrderBooking([
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
'department' => $department['id'],
|
||||||
|
'po' => 'DELETED-BOOKING-PO',
|
||||||
|
'deleted_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
|
||||||
|
'customer_number' => $customer['customer_number'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createResponse = api_client()->post('/orders', [
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ORDER-BOOKING-PO-MATCH',
|
||||||
|
'notes' => 'Created with matching booking',
|
||||||
|
'reg_1' => 'MATCHPO',
|
||||||
|
'booking_id' => $matchingBooking['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$createResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
|
||||||
|
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
|
||||||
|
|
||||||
|
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
|
||||||
|
'customer_number' => $otherCustomer['customer_number'],
|
||||||
|
]);
|
||||||
|
$unauthorizedResponse = api_client()->post('/orders', [
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ORDER-BOOKING-PO-UNAUTHORIZED',
|
||||||
|
'notes' => 'Created without booking access',
|
||||||
|
'reg_1' => 'NOAUTHPO',
|
||||||
|
'booking_id' => $matchingBooking['id'],
|
||||||
|
], $unauthorizedSession['headers']);
|
||||||
|
|
||||||
|
$unauthorizedResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$unauthorizedOrderId = (int)($unauthorizedResponse->data()['id'] ?? 0);
|
||||||
|
expect($unauthorizedResponse->data()['po'] ?? null)->toBeNull();
|
||||||
|
|
||||||
|
$foreignResponse = api_client()->post('/orders', [
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ORDER-BOOKING-PO-FOREIGN',
|
||||||
|
'notes' => 'Created with foreign booking',
|
||||||
|
'reg_1' => 'FOREIGNPO',
|
||||||
|
'booking_id' => $foreignBooking['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$foreignResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$foreignOrderId = (int)($foreignResponse->data()['id'] ?? 0);
|
||||||
|
expect($foreignResponse->data()['po'] ?? null)->toBeNull();
|
||||||
|
|
||||||
|
$deletedResponse = api_client()->post('/orders', [
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ORDER-BOOKING-PO-DELETED',
|
||||||
|
'notes' => 'Created with deleted booking',
|
||||||
|
'reg_1' => 'DELETEPO',
|
||||||
|
'booking_id' => $deletedBooking['id'],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$deletedResponse
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$deletedOrderId = (int)($deletedResponse->data()['id'] ?? 0);
|
||||||
|
expect($deletedResponse->data()['po'] ?? null)->toBeNull();
|
||||||
|
|
||||||
|
$existingOrder = api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $customer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $department['id'],
|
||||||
|
'reference' => 'ORDER-BOOKING-PO-UPDATE',
|
||||||
|
'reg_1' => 'UPDATEPO',
|
||||||
|
]);
|
||||||
|
|
||||||
|
api_client()->put('/orders', [
|
||||||
|
'id' => $existingOrder['id'],
|
||||||
|
'booking_id' => $foreignBooking['id'],
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$updatedRow = api_fixtures()->fetchRowById('orders', (int)$existingOrder['id']);
|
||||||
|
expect($updatedRow['po'] ?? null)->toBeNull();
|
||||||
|
|
||||||
|
api_fixtures()->cleanupDeleteById('orders', $matchingOrderId);
|
||||||
|
api_fixtures()->cleanupDeleteById('orders', $unauthorizedOrderId);
|
||||||
|
api_fixtures()->cleanupDeleteById('orders', $foreignOrderId);
|
||||||
|
api_fixtures()->cleanupDeleteById('orders', $deletedOrderId);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid order creation requests', function (): void {
|
it('rejects invalid order creation requests', function (): void {
|
||||||
api_test_covers('POST /orders', 'failure');
|
api_test_covers('POST /orders', 'failure');
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ it('returns the updated lane id after editing a scanner whose null lane was alre
|
|||||||
'add_department_lane',
|
'add_department_lane',
|
||||||
'add_number_plate_scanner',
|
'add_number_plate_scanner',
|
||||||
'edit_number_plate_scanner',
|
'edit_number_plate_scanner',
|
||||||
|
'department_access_' . (int)$department['id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$laneName = 'Lane ' . uniqid('', false);
|
$laneName = 'Lane ' . uniqid('', false);
|
||||||
|
|||||||
@@ -179,6 +179,59 @@ it('orders reference suggestions by match relevance before context and frequency
|
|||||||
expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']);
|
expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not return vehicle reference suggestions outside the authorized department context', function (): void {
|
||||||
|
api_test_covers('GET /orders/reference-suggestions', 'security');
|
||||||
|
|
||||||
|
$authorizedDepartment = api_fixtures()->createDepartment();
|
||||||
|
$otherDepartment = api_fixtures()->createDepartment();
|
||||||
|
$authorizedCustomer = api_fixtures()->createUser(['display_name' => 'Authorized Reference Customer']);
|
||||||
|
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Tenant Reference Customer']);
|
||||||
|
$cashier = api_fixtures()->createUser(['display_name' => 'Reference Security Cashier']);
|
||||||
|
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $authorizedCustomer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $authorizedDepartment['id'],
|
||||||
|
'reference' => 'SAFE-DEPARTMENT-REF',
|
||||||
|
'reg_1' => 'SAFE1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createOrder([
|
||||||
|
'customer_id' => $otherCustomer['customer_number'],
|
||||||
|
'cashier_id' => $cashier['id'],
|
||||||
|
'department_id' => $otherDepartment['id'],
|
||||||
|
'reference' => 'LEAK-ORDER-REF',
|
||||||
|
'reg_1' => 'LEAK1',
|
||||||
|
]);
|
||||||
|
api_fixtures()->createVehicle([
|
||||||
|
'customer_id' => $otherCustomer['customer_number'],
|
||||||
|
'type' => 53,
|
||||||
|
'reg' => 'LEAK1',
|
||||||
|
'reference' => 'LEAK-VEHICLE-REF',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'list_orders',
|
||||||
|
'department_access_' . $authorizedDepartment['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->get('/orders/reference-suggestions?' . http_build_query([
|
||||||
|
'search' => 'LEAK',
|
||||||
|
'department_id' => $authorizedDepartment['id'],
|
||||||
|
'customer_id' => $otherCustomer['customer_number'],
|
||||||
|
'reg_1' => 'LEAK1',
|
||||||
|
]), $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$suggestions = $response->data();
|
||||||
|
expect($suggestions)->toBeArray();
|
||||||
|
expect(reference_suggestion_by_reference($suggestions, 'LEAK-VEHICLE-REF'))->toBeNull();
|
||||||
|
expect(reference_suggestion_by_reference($suggestions, 'LEAK-ORDER-REF'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('enforces authentication, list permission, and department access for reference suggestions', function (): void {
|
it('enforces authentication, list permission, and department access for reference suggestions', function (): void {
|
||||||
api_test_covers('GET /orders/reference-suggestions', 'auth');
|
api_test_covers('GET /orders/reference-suggestions', 'auth');
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ it('creates a comprehensive self-serve API scenario with demo relays', function
|
|||||||
->and($session['reg'])->toBe($scenario['vehicle']['reg']);
|
->and($session['reg'])->toBe($scenario['vehicle']['reg']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void {
|
it('creates self-serve invoice orders for the lane customer with draft customer and driver metadata attached', function (): void {
|
||||||
selfserve_fixture_ensure_legacy_redis_constant();
|
selfserve_fixture_ensure_legacy_redis_constant();
|
||||||
|
|
||||||
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']);
|
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']);
|
||||||
@@ -82,7 +82,7 @@ it('creates self-serve invoice orders on the draft customer with original custom
|
|||||||
api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]);
|
api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]);
|
||||||
|
|
||||||
expect($order)->not->toBeNull()
|
expect($order)->not->toBeNull()
|
||||||
->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number'])
|
->and((int)$order['customer_id'])->toBe((int)$scenario['customer']['customer_number'])
|
||||||
->and((int)$order['department_id'])->toBe((int)$scenario['department']['id'])
|
->and((int)$order['department_id'])->toBe((int)$scenario['department']['id'])
|
||||||
->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg'])
|
->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg'])
|
||||||
->and((int)$order['lane'])->toBe((int)$scenario['lane']['id'])
|
->and((int)$order['lane'])->toBe((int)$scenario['lane']['id'])
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function selfserve_lane_command_ensure_legacy_redis_constant(): void
|
||||||
|
{
|
||||||
|
if (defined('redis')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $REDIS_CONFIG;
|
||||||
|
|
||||||
|
$REDIS_CONFIG = [
|
||||||
|
'host' => getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis',
|
||||||
|
'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default',
|
||||||
|
'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0',
|
||||||
|
'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '',
|
||||||
|
'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379',
|
||||||
|
];
|
||||||
|
|
||||||
|
define('redis', (new \classes\redis())->connect());
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfserve_lane_command_lane(int $laneId): \modules\selfserve\classes\selfserve_lane
|
||||||
|
{
|
||||||
|
selfserve_lane_command_ensure_legacy_redis_constant();
|
||||||
|
|
||||||
|
return (new \classes\selfserve())->lane($laneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfserve_lane_command_make_available(int $laneId): void
|
||||||
|
{
|
||||||
|
$lane = selfserve_lane_command_lane($laneId);
|
||||||
|
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE);
|
||||||
|
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IDLE);
|
||||||
|
$lane->setCustomerNumber(0);
|
||||||
|
$lane->setLicensePlate('');
|
||||||
|
$lane->setWashStartTime(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfserve_lane_command_make_occupied(int $laneId, int $customerNumber, string $licensePlate): void
|
||||||
|
{
|
||||||
|
$lane = selfserve_lane_command_lane($laneId);
|
||||||
|
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
|
||||||
|
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
|
||||||
|
$lane->setCustomerNumber($customerNumber);
|
||||||
|
$lane->setLicensePlate($licensePlate);
|
||||||
|
$lane->setWashStartTime(time() - 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allows customer self-serve permission to execute START without department access when department and lane are enabled', function (): void {
|
||||||
|
$group = api_fixtures()->createGroup([], [
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'customer' => ['group_id' => $group['id']],
|
||||||
|
'department_selfserve_enabled' => true,
|
||||||
|
'lane_selfserve_enabled' => true,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_available((int)$scenario['lane']['id']);
|
||||||
|
$token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'START',
|
||||||
|
'license_plate' => (string)$scenario['vehicle']['reg'],
|
||||||
|
'defer_relay_side_effects' => true,
|
||||||
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect($response->data()['status'] ?? null)->toBe('OCCUPIED')
|
||||||
|
->and($response->data()['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer START when department self-serve is disabled', function (): void {
|
||||||
|
$group = api_fixtures()->createGroup([], [
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'customer' => ['group_id' => $group['id']],
|
||||||
|
'department_selfserve_enabled' => false,
|
||||||
|
'lane_selfserve_enabled' => true,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_available((int)$scenario['lane']['id']);
|
||||||
|
$token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'START',
|
||||||
|
'license_plate' => (string)$scenario['vehicle']['reg'],
|
||||||
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertMissingPermissions([
|
||||||
|
'department_access_' . (int)$scenario['department']['id'],
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer START when lane self-serve is disabled', function (): void {
|
||||||
|
$group = api_fixtures()->createGroup([], [
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'customer' => ['group_id' => $group['id']],
|
||||||
|
'department_selfserve_enabled' => true,
|
||||||
|
'lane_selfserve_enabled' => false,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_available((int)$scenario['lane']['id']);
|
||||||
|
$token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'START',
|
||||||
|
'license_plate' => (string)$scenario['vehicle']['reg'],
|
||||||
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertMissingPermissions([
|
||||||
|
'department_access_' . (int)$scenario['department']['id'],
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a customer STOP for another customers active lane', function (): void {
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'department_selfserve_enabled' => true,
|
||||||
|
'lane_selfserve_enabled' => true,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_occupied(
|
||||||
|
(int)$scenario['lane']['id'],
|
||||||
|
(int)$scenario['customer']['customer_number'],
|
||||||
|
(string)$scenario['vehicle']['reg']
|
||||||
|
);
|
||||||
|
$otherSession = api_fixtures()->createUserSession([
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'STOP',
|
||||||
|
], $otherSession['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertMissingPermissions([
|
||||||
|
'department_access_' . (int)$scenario['department']['id'],
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows elevated operators with department access to execute lane commands', function (): void {
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'department_selfserve_enabled' => false,
|
||||||
|
'lane_selfserve_enabled' => false,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_available((int)$scenario['lane']['id']);
|
||||||
|
$session = api_fixtures()->createUserSession([
|
||||||
|
'department_access_' . (int)$scenario['department']['id'],
|
||||||
|
'modules_selfserve_lane_command_execute',
|
||||||
|
'modules_selfserve_lane_command_execute_start',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'START',
|
||||||
|
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
|
||||||
|
'defer_relay_side_effects' => true,
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSuccess(true);
|
||||||
|
|
||||||
|
expect($response->data()['status'] ?? null)->toBe('OCCUPIED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps operator-only commands elevated-only for customers and reports command permissions', function (): void {
|
||||||
|
$group = api_fixtures()->createGroup([], [
|
||||||
|
'list_own_department_selfserve_vehicle_conditions',
|
||||||
|
]);
|
||||||
|
$scenario = api_fixtures()->createSelfServeScenario([
|
||||||
|
'customer' => ['group_id' => $group['id']],
|
||||||
|
'department_selfserve_enabled' => true,
|
||||||
|
'lane_selfserve_enabled' => true,
|
||||||
|
]);
|
||||||
|
selfserve_lane_command_make_available((int)$scenario['lane']['id']);
|
||||||
|
$token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']);
|
||||||
|
|
||||||
|
$response = api_client()->post('/modules/self-serve/lane/command', [
|
||||||
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
|
'command' => 'RESET',
|
||||||
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(403)
|
||||||
|
->assertMissingPermissions([
|
||||||
|
'department_access_' . (int)$scenario['department']['id'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -304,6 +304,7 @@ it('returns a setup required error when creating a payment intent for a departme
|
|||||||
]);
|
]);
|
||||||
$session = api_fixtures()->createUserSession([
|
$session = api_fixtures()->createUserSession([
|
||||||
'charge_order',
|
'charge_order',
|
||||||
|
'department_access_' . (int)$department['id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = api_client()->post('/orders/module/stripe/payment_intent', [
|
$response = api_client()->post('/orders/module/stripe/payment_intent', [
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"dev": {
|
"dev": {
|
||||||
"auth_token_superuser": "0e432395f28bbc3f67093d9cabc2723f4c5b75ec340a12f3e7464b17ff18af38",
|
"auth_token_superuser": "REPLACE_WITH_DEV_SUPERUSER_TOKEN",
|
||||||
"auth_token_customer": "60e5748ebc497ad4d476b36d82d3668fff0b7891a9924c817930b50ab106360a",
|
"auth_token_customer": "REPLACE_WITH_DEV_CUSTOMER_TOKEN",
|
||||||
"auth_token_employee": "d3ed2b46ea550522e2ec867e27f62cdfcdd45ada3415c8db20d0f4a8df157ecf",
|
"auth_token_employee": "REPLACE_WITH_DEV_EMPLOYEE_TOKEN",
|
||||||
"auth_token_license_plate_scanner": "28f132fb79b76a85657d700e20f65f20bdd29ff790d1e17f39eec4fc6d116367",
|
"auth_token_license_plate_scanner": "REPLACE_WITH_DEV_LICENSE_PLATE_SCANNER_TOKEN",
|
||||||
"auth_token_invalid": "invalid_dev_secret_key"
|
"auth_token_invalid": "invalid_dev_secret_key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,47 @@ final class ApiFixtures
|
|||||||
return ['id' => $departmentId];
|
return ['id' => $departmentId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void
|
||||||
|
{
|
||||||
|
if ($departmentId <= 0) {
|
||||||
|
throw new RuntimeException('Department self-serve fixtures require a positive department id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cleanupDeleteWhere('department_variables', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'variable' => 'selfserve_enabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingIds = $this->fetchIntColumnWhere('department_variables', 'id', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'variable' => 'selfserve_enabled',
|
||||||
|
]);
|
||||||
|
if ($existingIds !== []) {
|
||||||
|
$this->updateById('department_variables', (int)$existingIds[0], [
|
||||||
|
'value' => $enabled ? 'true' : 'false',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variableId = $this->insertRowWithExistingColumns('department_variables', [
|
||||||
|
'department_id' => $departmentId,
|
||||||
|
'variable' => 'selfserve_enabled',
|
||||||
|
'value' => $enabled ? 'true' : 'false',
|
||||||
|
]);
|
||||||
|
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLaneSelfServeEnabled(int $laneId, bool $enabled): void
|
||||||
|
{
|
||||||
|
if ($laneId <= 0) {
|
||||||
|
throw new RuntimeException('Lane self-serve fixtures require a positive lane id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateById('department_lanes', $laneId, [
|
||||||
|
'selfserve_enabled' => $enabled ? 1 : 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $attributes
|
* @param array<string, mixed> $attributes
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -295,6 +336,14 @@ final class ApiFixtures
|
|||||||
$laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData);
|
$laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData);
|
||||||
$this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId));
|
$this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId));
|
||||||
|
|
||||||
|
$this->setDepartmentSelfServeEnabled(
|
||||||
|
(int)$department['id'],
|
||||||
|
(bool)($overrides['department_selfserve_enabled'] ?? true)
|
||||||
|
);
|
||||||
|
if (array_key_exists('lane_selfserve_enabled', $overrides)) {
|
||||||
|
$this->setLaneSelfServeEnabled($laneId, (bool)$overrides['lane_selfserve_enabled']);
|
||||||
|
}
|
||||||
|
|
||||||
$customer = $this->createUser(array_merge([
|
$customer = $this->createUser(array_merge([
|
||||||
'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix),
|
'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix),
|
||||||
'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix),
|
'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix),
|
||||||
@@ -934,7 +983,7 @@ final class ApiFixtures
|
|||||||
{
|
{
|
||||||
$subuser = $this->createSubuser($subuserAttributes);
|
$subuser = $this->createSubuser($subuserAttributes);
|
||||||
$this->grantSubuser((int)$subuser['id'], $customerNumber, $permissions);
|
$this->grantSubuser((int)$subuser['id'], $customerNumber, $permissions);
|
||||||
$token = $this->createAuthToken((int)$subuser['id'], 'AUTH_TOKEN_SUBUSER');
|
$token = $this->createCachedSubuserSessionToken((int)$subuser['id']);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => [
|
'user' => [
|
||||||
@@ -948,6 +997,22 @@ final class ApiFixtures
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createCachedSubuserSessionToken(int $subuserId): string
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
throw new RuntimeException('API tests require Redis for subuser session fixtures.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$cacheKey = '`subusers`_subuser_sessions_session_token:' . $token;
|
||||||
|
|
||||||
|
$this->redis->set($cacheKey, (string)$subuserId);
|
||||||
|
$this->redis->expire($cacheKey, 7 * 24 * 60 * 60);
|
||||||
|
$this->cleanup->add(fn() => $this->deleteRedisKey($cacheKey));
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
public function createAuthToken(int $userId, string $type = 'AUTH_TOKEN', ?string $token = null): string
|
public function createAuthToken(int $userId, string $type = 'AUTH_TOKEN', ?string $token = null): string
|
||||||
{
|
{
|
||||||
$token = $token ?: bin2hex(random_bytes(32));
|
$token = $token ?: bin2hex(random_bytes(32));
|
||||||
|
|||||||
@@ -668,6 +668,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($manager)->toContain('gateway_route_autoprovision');
|
expect($manager)->toContain('gateway_route_autoprovision');
|
||||||
expect($manager)->toContain('upsertDeploymentTarget');
|
expect($manager)->toContain('upsertDeploymentTarget');
|
||||||
expect($manager)->toContain('startDeployment');
|
expect($manager)->toContain('startDeployment');
|
||||||
|
expect($manager)->toContain("'commit_mode' => \$sourceCommitSha === '' ? 'latest' : 'specific'");
|
||||||
|
expect($manager)->toContain("\$deploymentInput['commit_sha'] = \$sourceCommitSha;");
|
||||||
expect($manager)->toContain('verifyGatewayRoutes');
|
expect($manager)->toContain('verifyGatewayRoutes');
|
||||||
expect($manager)->toContain('bootstrapGatewayCertificates');
|
expect($manager)->toContain('bootstrapGatewayCertificates');
|
||||||
expect($manager)->toContain('setLoadBalancerIpTargets');
|
expect($manager)->toContain('setLoadBalancerIpTargets');
|
||||||
|
|||||||
+2
@@ -12,6 +12,8 @@ it('wires complaint create, lookup, list, edit, and delete routes with validatio
|
|||||||
expect($content)->toContain("hasPermission('create_department_daily_report_complaints')");
|
expect($content)->toContain("hasPermission('create_department_daily_report_complaints')");
|
||||||
expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')");
|
expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')");
|
||||||
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
|
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
|
||||||
|
expect($content)->toContain('requireDepartmentAccess((int)$complaint->department_id->value())');
|
||||||
|
expect($content)->toContain("'department_id' => \$user->getGroup()->getDepartments()");
|
||||||
expect($content)->toContain("getOrImportCustomerByCustomerNumber");
|
expect($content)->toContain("getOrImportCustomerByCustomerNumber");
|
||||||
expect($content)->toContain("Search must be at least 2 characters");
|
expect($content)->toContain("Search must be at least 2 characters");
|
||||||
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
|
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ it('returns pagination metadata and strict status handling for collected-invoice
|
|||||||
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||||
expect($endpointBlock)->toContain('$statuses = $this->parseCollectedInvoiceQueueStatuses();');
|
expect($endpointBlock)->toContain('$statuses = $this->parseCollectedInvoiceQueueStatuses();');
|
||||||
expect($endpointBlock)->toContain("['limit' => \$limit, 'offset' => \$offset] = \$this->parseCollectedInvoiceQueuePagination();");
|
expect($endpointBlock)->toContain("['limit' => \$limit, 'offset' => \$offset] = \$this->parseCollectedInvoiceQueuePagination();");
|
||||||
expect($endpointBlock)->toContain('$jobs = $queue->listJobs(');
|
expect($endpointBlock)->toContain('$jobs = $queue->listJobsForCreatedBy(');
|
||||||
expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
|
expect($endpointBlock)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
|
||||||
expect($endpointBlock)->toContain('$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses);');
|
expect($endpointBlock)->toContain('$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses, (int)$user->id);');
|
||||||
expect($endpointBlock)->toContain("'items' => \$this->withCollectedInvoiceQueueDetailsSummaryList(\$jobs)");
|
expect($endpointBlock)->toContain("'items' => \$this->withCollectedInvoiceQueueDetailsSummaryList(\$jobs)");
|
||||||
expect($endpointBlock)->toContain("'total' => \$total_jobs");
|
expect($endpointBlock)->toContain("'total' => \$total_jobs");
|
||||||
expect($endpointBlock)->toContain("'limit' => \$limit");
|
expect($endpointBlock)->toContain("'limit' => \$limit");
|
||||||
@@ -29,9 +29,10 @@ it('returns pagination metadata and strict status handling for collected-invoice
|
|||||||
expect($content)->toContain('private function parseCollectedInvoiceQueuePagination(): array');
|
expect($content)->toContain('private function parseCollectedInvoiceQueuePagination(): array');
|
||||||
expect($content)->toContain('limit must be between 1 and 500');
|
expect($content)->toContain('limit must be between 1 and 500');
|
||||||
expect($content)->toContain('offset must be at least 0');
|
expect($content)->toContain('offset must be at least 0');
|
||||||
expect($content)->toContain('private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int');
|
expect($content)->toContain('private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses, int $created_by): int');
|
||||||
expect($content)->toContain("method_exists(\$queue, 'countJobs')");
|
expect($content)->toContain("method_exists(\$queue, 'countJobsForCreatedBy')");
|
||||||
expect($content)->toContain("SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE ");
|
expect($content)->toContain("SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE ");
|
||||||
|
expect($content)->toContain("'created_by = ' . \$created_by");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces retry constraints for collected-invoice queue jobs before retry execution', function (): void {
|
it('enforces retry constraints for collected-invoice queue jobs before retry execution', function (): void {
|
||||||
@@ -48,7 +49,7 @@ it('enforces retry constraints for collected-invoice queue jobs before retry exe
|
|||||||
|
|
||||||
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
$endpointBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||||
expect($endpointBlock)->toContain('$job_id = $this->requireCollectedInvoiceQueueJobId();');
|
expect($endpointBlock)->toContain('$job_id = $this->requireCollectedInvoiceQueueJobId();');
|
||||||
expect($endpointBlock)->toContain('$job = $this->requireCollectedInvoiceQueueJobById($job_id, true);');
|
expect($endpointBlock)->toContain('$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id, true);');
|
||||||
expect($endpointBlock)->toContain("Collected invoice queue job reached max retry attempts', 409");
|
expect($endpointBlock)->toContain("Collected invoice queue job reached max retry attempts', 409");
|
||||||
expect($endpointBlock)->toContain('Failed to retry collected invoice queue job: ');
|
expect($endpointBlock)->toContain('Failed to retry collected invoice queue job: ');
|
||||||
expect($endpointBlock)->toContain("'job' => \$this->withCollectedInvoiceQueueDetailsSummary(\$retried)");
|
expect($endpointBlock)->toContain("'job' => \$this->withCollectedInvoiceQueueDetailsSummary(\$retried)");
|
||||||
@@ -56,7 +57,7 @@ it('enforces retry constraints for collected-invoice queue jobs before retry exe
|
|||||||
expect($content)->toContain("\$response->success(\$this->withCollectedInvoiceQueueDetailsSummary(\$job));");
|
expect($content)->toContain("\$response->success(\$this->withCollectedInvoiceQueueDetailsSummary(\$job));");
|
||||||
|
|
||||||
expect($content)->toContain('private function requireCollectedInvoiceQueueJobId(): int');
|
expect($content)->toContain('private function requireCollectedInvoiceQueueJobId(): int');
|
||||||
expect($content)->toContain('private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array');
|
expect($content)->toContain('private function requireCollectedInvoiceQueueJobById(int $job_id, int $created_by, bool $mustBeFailed = false): array');
|
||||||
expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409");
|
expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409");
|
||||||
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array');
|
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array');
|
||||||
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array');
|
expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array');
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('scopes economic transfer queue route reads and retries to the current user', function (): void {
|
||||||
|
$economic_invoice_route = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
|
||||||
|
$collected_invoice_route = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
||||||
|
$queue = file_get_contents(app_path('classes/economic_transfer_queue.php'));
|
||||||
|
|
||||||
|
expect($economic_invoice_route)->not->toBeFalse();
|
||||||
|
expect($collected_invoice_route)->not->toBeFalse();
|
||||||
|
expect($queue)->not->toBeFalse();
|
||||||
|
|
||||||
|
expect($economic_invoice_route)->toContain('$queue->getJobByIdForUser((int)$job_id, (int)$user->id)');
|
||||||
|
expect($economic_invoice_route)->toContain('$queue->retryJobForUser((int)$job_id, (int)$user->id)');
|
||||||
|
|
||||||
|
expect($collected_invoice_route)->toContain('$queue->listJobsForCreatedBy(');
|
||||||
|
expect($collected_invoice_route)->toContain('$this->countCollectedInvoiceQueueJobs($queue, $statuses, (int)$user->id)');
|
||||||
|
expect($collected_invoice_route)->toContain('private function requireCollectedInvoiceQueueJobById(int $job_id, int $created_by, bool $mustBeFailed = false): array');
|
||||||
|
expect($collected_invoice_route)->toContain('$queue->getJobByIdForUser($job_id, $created_by)');
|
||||||
|
expect($collected_invoice_route)->toContain('$queue->retryJobForUser($job_id, (int)$user->id)');
|
||||||
|
|
||||||
|
expect($queue)->toContain('SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1');
|
||||||
|
expect($queue)->toContain('WHERE q.created_by = $user_id');
|
||||||
|
expect($queue)->toContain('AND created_by = ?');
|
||||||
|
});
|
||||||
@@ -7,16 +7,20 @@ it('hardens transfer queue with type validation retry caps and stale lock recove
|
|||||||
expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900');
|
expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900');
|
||||||
expect($content)->toContain('$this->validateTransferType($transfer_type)');
|
expect($content)->toContain('$this->validateTransferType($transfer_type)');
|
||||||
expect($content)->toContain('$this->normalizePayloadForTransferType($transfer_type, $payload, $created_by)');
|
expect($content)->toContain('$this->normalizePayloadForTransferType($transfer_type, $payload, $created_by)');
|
||||||
expect($content)->toContain('$this->findActiveJobByTarget($transfer_type, $payload)');
|
expect($content)->toContain('$this->findActiveJobByTarget($transfer_type, $payload, $created_by)');
|
||||||
expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));');
|
expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));');
|
||||||
expect($content)->toContain('private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array');
|
expect($content)->toContain('private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array');
|
||||||
expect($content)->toContain('private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool');
|
expect($content)->toContain('private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool');
|
||||||
expect($content)->toContain('private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array');
|
expect($content)->toContain('private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array');
|
||||||
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_DEDUPED');
|
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_DEDUPED');
|
||||||
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED');
|
expect($content)->toContain('ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED');
|
||||||
expect($content)->toContain('Queue job reached max retry attempts');
|
expect($content)->toContain('Queue job reached max retry attempts');
|
||||||
expect($content)->toContain('AND attempts < max_attempts');
|
expect($content)->toContain('AND attempts < max_attempts');
|
||||||
expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array');
|
expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array');
|
||||||
|
expect($content)->toContain('public function getJobByIdForUser(int $job_id, int $created_by): ?array');
|
||||||
|
expect($content)->toContain('public function listJobsForCreatedBy(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null, int $created_by = 0): array');
|
||||||
|
expect($content)->toContain('public function countJobsForCreatedBy(array $statuses = [], ?string $transfer_type = null, int $created_by = 0): int');
|
||||||
|
expect($content)->toContain('public function retryJobForUser(int $job_id, int $created_by): array');
|
||||||
expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int');
|
expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int');
|
||||||
expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array');
|
expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array');
|
||||||
expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array');
|
expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array');
|
||||||
|
|||||||
@@ -77,3 +77,15 @@ it('keeps legacy compare endpoint path for backward compatibility', function ():
|
|||||||
expect($content)->toContain('/collected-invoices/economic/compare');
|
expect($content)->toContain('/collected-invoices/economic/compare');
|
||||||
expect($content)->toContain("requirePermission('compare_collected_invoice_economic')");
|
expect($content)->toContain("requirePermission('compare_collected_invoice_economic')");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enforces customer or department context before building v2 invoice details payload', function (): void {
|
||||||
|
$routeFile = app_path('routes/orderInvoicesRoute.php');
|
||||||
|
$content = file_get_contents($routeFile);
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain('private function requireCollectedInvoiceContextAccess');
|
||||||
|
expect($content)->toContain('$this->requireCollectedInvoiceContextAccess($invoice);');
|
||||||
|
expect($content)->toContain("$this->hasPermission('superuser')");
|
||||||
|
expect($content)->toContain('$this->isOwnCustomerContext($invoice_customer_number)');
|
||||||
|
expect($content)->toContain('$this->hasAccessToAllCollectedInvoiceDepartments((int)$invoice->id)');
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,31 @@ function invoice_period_flag_service_invoke(string $method, array $args = []): m
|
|||||||
return $target->invokeArgs($service, $args);
|
return $target->invokeArgs($service, $args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('normalizes invoice period date ranges to full-day timestamps before cache-backed reads', function (): void {
|
||||||
|
expect(invoice_period_flag_service_invoke('normalizePeriodDateRange', ['2025-03-01', '2025-03-31']))
|
||||||
|
->toBe(['2025-03-01 00:00:00', '2025-03-31 23:59:59']);
|
||||||
|
|
||||||
|
expect(invoice_period_flag_service_invoke('normalizePeriodDateRange', [
|
||||||
|
'2025-03-01 00:00:00',
|
||||||
|
'2025-03-31 23:59:59',
|
||||||
|
]))->toBe(['2025-03-01 00:00:00', '2025-03-31 23:59:59']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes invoice period redis keys for bare dates and normalized API timestamps', function (): void {
|
||||||
|
$reflection = new ReflectionClass(\classes\redis::class);
|
||||||
|
$redis = $reflection->newInstanceWithoutConstructor();
|
||||||
|
$key = $reflection->getMethod('invoicePeriodCacheKey');
|
||||||
|
$key->setAccessible(true);
|
||||||
|
|
||||||
|
expect($key->invoke($redis, 'invoice_period_automatic_flags', '2025-03-01', '2025-03-31'))
|
||||||
|
->toBe($key->invoke(
|
||||||
|
$redis,
|
||||||
|
'invoice_period_automatic_flags',
|
||||||
|
'2025-03-01 00:00:00',
|
||||||
|
'2025-03-31 23:59:59'
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void {
|
it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void {
|
||||||
$row = [
|
$row = [
|
||||||
'customer_number' => 424242,
|
'customer_number' => 424242,
|
||||||
@@ -628,6 +653,25 @@ it('does not report a price mismatch when a product-specific discount makes the
|
|||||||
expect($flags)->toBe([]);
|
expect($flags)->toBe([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat subset primary vehicle product names as equivalent', function (): void {
|
||||||
|
expect(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [5, 'Forvogn', 6, 'Forvogn med hænger']))
|
||||||
|
->toBeFalse()
|
||||||
|
->and(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [10, 'Indvendig vask Kassevogn', 11, 'Kassevogn']))
|
||||||
|
->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds automatic invoice period data on cache misses instead of hiding warnings', function (): void {
|
||||||
|
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
expect($content)->toContain('$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);');
|
||||||
|
expect($content)->toContain('$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);');
|
||||||
|
expect($content)->toContain('cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows)');
|
||||||
|
expect($content)->not->toContain('enqueue_invoice_period_warming($dateFrom, $dateTo)');
|
||||||
|
});
|
||||||
|
|
||||||
it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void {
|
it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void {
|
||||||
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,26 @@ it('streams the main period response instead of encoding the full payload at onc
|
|||||||
$content = (string)$content;
|
$content = (string)$content;
|
||||||
|
|
||||||
expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void')
|
expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void')
|
||||||
->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);')
|
->and($content)->toContain("\$includeInvoicePeriodFlags = \$this->hasPermission('list_invoice_period_flags');")
|
||||||
|
->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);')
|
||||||
->and($content)->toContain('self::streamInvoicingPeriodResponse($period);')
|
->and($content)->toContain('self::streamInvoicingPeriodResponse($period);')
|
||||||
->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)')
|
->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)')
|
||||||
->and($content)->toContain('echo self::jsonFragment($customer);');
|
->and($content)->toContain('echo self::jsonFragment($customer);');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only includes invoice period flags when the list permission is granted', function (): void {
|
||||||
|
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
expect($content)
|
||||||
|
->toContain("\$includeInvoicePeriodFlags = \$this->hasPermission('list_invoice_period_flags');")
|
||||||
|
->and($content)->toContain('bool $includeInvoicePeriodFlags = false')
|
||||||
|
->and($content)->toContain('if ($includeInvoicePeriodFlags) {')
|
||||||
|
->and($content)->toContain('applyFlagsToPeriodTypes(');
|
||||||
|
});
|
||||||
|
|
||||||
it('maps batched period transaction rows to the legacy transaction response shape', function (): void {
|
it('maps batched period transaction rows to the legacy transaction response shape', function (): void {
|
||||||
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
||||||
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
|
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('restricts absolute webhook URLs to configured n8n webhook host', function (): void {
|
||||||
|
$classFile = app_path('classes/n8n.php');
|
||||||
|
$content = file_get_contents($classFile);
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
expect($content)->toContain('isAllowedWebhookAbsoluteUrl');
|
||||||
|
expect($content)->toContain('Webhook URL must use the configured n8n webhook host.');
|
||||||
|
expect($content)->toContain("$targetHost !== $baseHost");
|
||||||
|
expect($content)->toContain("$targetScheme !== $baseScheme");
|
||||||
|
expect($content)->toContain('return $targetPort === $basePort;');
|
||||||
|
});
|
||||||
@@ -15,8 +15,12 @@ if (!class_exists('OrderBookingsCompletionOrderDouble')) {
|
|||||||
{
|
{
|
||||||
$this->id = -1;
|
$this->id = -1;
|
||||||
$this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false);
|
$this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false);
|
||||||
|
$this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true);
|
||||||
|
$this->department_id = new object_property('orders', -1, 'department_id', 'int', true);
|
||||||
$this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false);
|
$this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false);
|
||||||
$this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false);
|
$this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false);
|
||||||
|
$this->customer_id->set(111111);
|
||||||
|
$this->department_id->set(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool $washCertificateAttached = false;
|
public bool $washCertificateAttached = false;
|
||||||
@@ -52,8 +56,12 @@ if (!class_exists('OrderBookingsCompletionDouble')) {
|
|||||||
{
|
{
|
||||||
$this->id = -1;
|
$this->id = -1;
|
||||||
$this->linkedOrder = $linkedOrder;
|
$this->linkedOrder = $linkedOrder;
|
||||||
|
$this->customer_number = new object_property('order_bookings', -1, 'customer_number', 'int', true);
|
||||||
|
$this->department = new object_property('order_bookings', -1, 'department', 'int', true);
|
||||||
$this->order_id = new object_property('order_bookings', -1, 'order_id', 'int', false);
|
$this->order_id = new object_property('order_bookings', -1, 'order_id', 'int', false);
|
||||||
$this->items = new object_property('order_bookings', -1, 'items', 'json', false);
|
$this->items = new object_property('order_bookings', -1, 'items', 'json', false);
|
||||||
|
$this->customer_number->set(111111);
|
||||||
|
$this->department->set(10);
|
||||||
$this->items->set([]);
|
$this->items->set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +99,7 @@ if (!class_exists('OrderBookingsCompletionDouble')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('attaches and emails a wash certificate when a booking is already linked to a pos order without one', function (): void {
|
it('attaches and emails a wash certificate when a booking is already linked to a matching pos order without one', function (): void {
|
||||||
$order = new OrderBookingsCompletionOrderDouble();
|
$order = new OrderBookingsCompletionOrderDouble();
|
||||||
$booking = new OrderBookingsCompletionDouble($order);
|
$booking = new OrderBookingsCompletionDouble($order);
|
||||||
$booking->order_id->set(321);
|
$booking->order_id->set(321);
|
||||||
@@ -104,6 +112,22 @@ it('attaches and emails a wash certificate when a booking is already linked to a
|
|||||||
expect($booking->sendCalls)->toBe(1);
|
expect($booking->sendCalls)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects wash certificate completion when the linked pos order belongs to another booking context', function (): void {
|
||||||
|
$order = new OrderBookingsCompletionOrderDouble();
|
||||||
|
$order->customer_id->set(222222);
|
||||||
|
$order->department_id->set(99);
|
||||||
|
|
||||||
|
$booking = new OrderBookingsCompletionDouble($order);
|
||||||
|
$booking->order_id->set(321);
|
||||||
|
$booking->containsWashCertificate = true;
|
||||||
|
|
||||||
|
expect(fn() => $booking->completeBooking(77, 'LINKED-SEAL'))
|
||||||
|
->toThrow(Exception::class, 'Linked order does not match booking customer or department');
|
||||||
|
expect($order->getSafetySealValue())->toBeNull();
|
||||||
|
expect($booking->attachCalls)->toBe(0);
|
||||||
|
expect($booking->sendCalls)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the linked pos order wash certificate item added during mobile completion', function (): void {
|
it('uses the linked pos order wash certificate item added during mobile completion', function (): void {
|
||||||
$order = new OrderBookingsCompletionOrderDouble();
|
$order = new OrderBookingsCompletionOrderDouble();
|
||||||
$order->containsWashCertificate = true;
|
$order->containsWashCertificate = true;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user