Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f2c80dda | ||
|
|
6f3d7e0f7d | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
38814545c4 | ||
|
|
94c3654240 | ||
|
|
9fa249cc11 | ||
|
|
215c8d0fbb | ||
|
|
3f41eebdf6 | ||
|
|
64e0b2444b | ||
|
|
18fede78f8 | ||
|
|
243d68ab59 | ||
|
|
62c1393f62 | ||
|
|
f0a8299133 | ||
|
|
e36f6da926 | ||
|
|
4a8c2a9fd9 | ||
|
|
248b2e4eca | ||
|
|
1d1ebd2176 | ||
|
|
4252f9a42b | ||
|
|
4fdeedab45 | ||
|
|
866a5be126 | ||
|
|
11d39af934 | ||
|
|
f706531534 | ||
|
|
a839eac4c1 | ||
|
|
581d28e9ce | ||
|
|
2ba39b8174 | ||
|
|
6af55a44c9 | ||
|
|
24badc39d7 | ||
|
|
f5e0baaab6 | ||
|
|
178c84ba60 | ||
|
|
713d40a876 | ||
|
|
9db1964038 | ||
|
|
1ca42055b0 | ||
|
|
c04bda7368 | ||
|
|
1f47843699 | ||
|
|
dca738db82 | ||
|
|
57f364ad0f | ||
|
|
a826153bb5 | ||
|
|
72bd22a707 | ||
|
|
cc73d80dbc | ||
|
|
f0a5b15442 | ||
|
|
eefe5630f4 | ||
|
|
b0ea771e6a | ||
|
|
eb21405a3d | ||
|
|
8ea10ef808 | ||
|
|
ac7da807bd | ||
|
|
3eafc597c6 | ||
|
|
f7a4126718 | ||
|
|
c6cf953ede | ||
|
|
d902202fe9 | ||
|
|
acc80920f9 | ||
|
|
53246af629 | ||
|
|
bd87e94472 | ||
|
|
7ccbb68ffa | ||
|
|
4a7fc7c534 | ||
|
|
f4343ae114 | ||
|
|
0da02dfeb5 | ||
|
|
5f13242cfa | ||
|
|
b492292642 | ||
|
|
ce8ba88b16 | ||
|
|
02b6df5e3b | ||
|
|
6cc4f2759d | ||
|
|
3ea92be722 | ||
|
|
42f8ae0c47 | ||
|
|
148b575767 | ||
|
|
605efacece | ||
|
|
1ccd7749d0 | ||
|
|
b3ba3c8de5 | ||
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
d605eca574 | ||
|
|
cea469c95a | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 | ||
|
|
e26034dfae | ||
|
|
0aad41fd0f | ||
|
|
5c67fe419f | ||
|
|
aca8be51dc | ||
|
|
fb1f0883e1 | ||
|
|
6446eb2e36 | ||
|
|
a1224ec2f4 | ||
|
|
07441c4ed1 | ||
|
|
cd100f1180 | ||
|
|
4fc66c72b8 | ||
|
|
a3ea5fee83 | ||
|
|
ef8d97c821 | ||
|
|
327a77edf4 | ||
|
|
d8abc8f87d | ||
|
|
325b35beb7 | ||
|
|
49364864d2 | ||
|
|
a19178a042 | ||
|
|
91d3332d4e | ||
|
|
bedbf21c29 | ||
|
|
75c19bcce4 | ||
|
|
458fe7399d | ||
|
|
2b6a8eedcc | ||
|
|
c0ed107f75 | ||
|
|
30dceff0b5 | ||
|
|
716929bd7b | ||
|
|
3d221f3379 | ||
|
|
6f1c160fbb | ||
|
|
9694695f00 | ||
|
|
1d43221b4d | ||
|
|
33b7c3e51a | ||
|
|
1e64bd63b8 | ||
|
|
bcbc2481c3 | ||
|
|
8288a1069c | ||
|
|
1b99523366 |
@@ -11,6 +11,8 @@ services:
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
ports:
|
||||
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
||||
labels:
|
||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||
@@ -80,3 +82,9 @@ services:
|
||||
|
||||
volumes:
|
||||
ci_php_app:
|
||||
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
qodana:
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
assign-task:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
|
||||
+122
-42
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -19,6 +19,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
@@ -44,7 +58,7 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -54,8 +68,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-agent/package-lock.json
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
@@ -91,12 +103,26 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -112,8 +138,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-broker/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: services/edge-broker
|
||||
@@ -125,26 +149,83 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
COMPOSE_PROFILES: dev
|
||||
TRAEFIK_WEB_PORT: "18080"
|
||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||
TRAEFIK_METRICS_PORT: "19100"
|
||||
EDGE_BROKER_CI_PORT: "14300"
|
||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find_free_port() {
|
||||
start="$1"
|
||||
end="$2"
|
||||
port="$start"
|
||||
while [ "$port" -le "$end" ]; do
|
||||
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
|
||||
echo "No free port in range ${start}-${end}." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
base=$((20000 + (GITHUB_RUN_ID % 20000)))
|
||||
web_port="$(find_free_port "$base" "$((base + 2000))")"
|
||||
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
|
||||
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
|
||||
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
|
||||
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
|
||||
|
||||
{
|
||||
echo "TRAEFIK_WEB_PORT=${web_port}"
|
||||
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
|
||||
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
|
||||
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
|
||||
echo "EDGE_BROKER_CI_PORT=${broker_port}"
|
||||
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
|
||||
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
|
||||
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -152,7 +233,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
@@ -161,10 +242,33 @@ jobs:
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
composer_install() {
|
||||
install_mode="$1"
|
||||
max_attempts="$2"
|
||||
attempt=1
|
||||
while :; do
|
||||
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
composer_install --prefer-dist 3 || {
|
||||
echo "Composer dist install failed; retrying with --prefer-source." >&2
|
||||
composer_install --prefer-source 2
|
||||
}
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
@@ -223,34 +327,10 @@ jobs:
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
env:
|
||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
@@ -258,7 +338,7 @@ jobs:
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
|
||||
@@ -279,7 +359,7 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}")"
|
||||
--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\":[\"api_gateway\"]}")"
|
||||
response_body="$(cat "$response_file")"
|
||||
rm -f "$response_file"
|
||||
|
||||
|
||||
+4
-1
@@ -13,4 +13,7 @@
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/.env_old_2
|
||||
/.openclaw/
|
||||
/services/nginx/app/build/phpstan/
|
||||
|
||||
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,6 +47,7 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,8 @@ services:
|
||||
traefik:
|
||||
image: traefik:2.11
|
||||
container_name: traefik
|
||||
group_add:
|
||||
- "${DOCKER_SOCKET_GID:-65534}"
|
||||
ports:
|
||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||
@@ -102,6 +104,7 @@ services:
|
||||
image: mysql:8.4
|
||||
container_name: mysql-debug
|
||||
profiles: [dev]
|
||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
||||
environment:
|
||||
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}
|
||||
|
||||
@@ -3357,6 +3357,9 @@
|
||||
},
|
||||
"email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"superuser_new_customer_email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12955,6 +12958,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/slack/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Get Slack config",
|
||||
"operationId": "getSlackConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SlackConfigListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Update Slack config",
|
||||
"operationId": "updateSlackConfig",
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/backups/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15496,6 +15547,39 @@
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"SlackConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Slack"
|
||||
]
|
||||
},
|
||||
"variable": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"customer_registration_webhook_url"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"example": "https://hooks.slack.com/services/..."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"module",
|
||||
"variable",
|
||||
"type",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"BackupsConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16240,6 +16324,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SlackConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SlackConfigEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BackupsConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+3051
-200
File diff suppressed because it is too large
Load Diff
+132545
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <service> [service ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
|
||||
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
|
||||
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
compose_up() {
|
||||
attempt=1
|
||||
while :; do
|
||||
docker network prune -f >/dev/null 2>&1 || true
|
||||
|
||||
if docker compose $compose_files up -d "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
status="$?"
|
||||
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
(
|
||||
flock 9
|
||||
compose_up "$@"
|
||||
) 9>"$lock_file"
|
||||
else
|
||||
echo "flock is not available; running Docker compose startup without a host lock." >&2
|
||||
compose_up "$@"
|
||||
fi
|
||||
@@ -0,0 +1,453 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-command-drain-proof.mjs [options]
|
||||
|
||||
Verifies that a broker-connected PHP compose edge agent still drains API-queued
|
||||
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createWorkerServer(state) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/relay/switch") {
|
||||
state.relaySwitchSeen = true;
|
||||
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
|
||||
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
online: true,
|
||||
on: true,
|
||||
output: true,
|
||||
raw: { source: "fake-worker" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, workerPort) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: "10.123.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, childExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
relaySwitchSeen: state.relaySwitchSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !childExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
relaySwitchSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const workerServer = createWorkerServer(state);
|
||||
let apiServer = null;
|
||||
let child = null;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let childExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const workerPort = await listen(workerServer);
|
||||
apiServer = createApiServer(state, brokerPort, workerPort);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
child = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !childExited) {
|
||||
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, childExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.relaySwitchSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.stdout = stdout.slice(-3000);
|
||||
error.stderr = stderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (child) {
|
||||
await stopChild(child);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(workerServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.stdout) {
|
||||
process.stderr.write(`stdout: ${error.stdout}\n`);
|
||||
}
|
||||
if (error.stderr) {
|
||||
process.stderr.write(`stderr: ${error.stderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_WORKER_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const AGENT_TOKEN = "agent-token";
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
workerPath: DEFAULT_WORKER_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--worker-path":
|
||||
options.workerPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-to-shelly-proof.mjs [options]
|
||||
|
||||
Runs the PHP edge agent and real LAN worker against fake broker, API, and
|
||||
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
|
||||
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
|
||||
call, reads Switch.GetStatus, and posts the command result.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--worker-path <path> PHP LAN worker artifact to execute.
|
||||
Default: ${DEFAULT_WORKER_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === null ? null : JSON.stringify(body);
|
||||
const request = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: requestPath,
|
||||
method,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(payload === null ? {} : {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
}),
|
||||
...headers,
|
||||
},
|
||||
timeout: 1000,
|
||||
}, (response) => {
|
||||
let raw = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded = {};
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
decoded = { __invalid: raw };
|
||||
}
|
||||
resolve({ status: response.statusCode || 0, body: decoded });
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error("request timed out"));
|
||||
});
|
||||
if (payload !== null) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function reservePort() {
|
||||
const server = net.createServer();
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
await closeServer(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createShellyServer(state) {
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
state.requests.push({
|
||||
service: "shelly",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
|
||||
state.shellySwitchSetSeen = true;
|
||||
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { was_on: false, output: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
|
||||
state.shellyStatusSeen = true;
|
||||
if (url.searchParams.get("id") !== "0") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/relay/")) {
|
||||
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, shellyAddress) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: shellyAddress,
|
||||
local_ip: shellyAddress,
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
relay_id: "relay-proof",
|
||||
deviceGeneration: 2,
|
||||
device_generation: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (
|
||||
body.ok !== true ||
|
||||
body.result?.on !== true ||
|
||||
body.result?.output !== true ||
|
||||
body.result?.raw?.source !== "fake-shelly-rpc"
|
||||
) {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: AGENT_TOKEN,
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnWorker({ workerPath, phpImage, workerPort }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-e",
|
||||
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
|
||||
"-v",
|
||||
`${workerPath}:/lan-worker.php:ro`,
|
||||
phpImage,
|
||||
"php",
|
||||
"-S",
|
||||
`127.0.0.1:${workerPort}`,
|
||||
"/lan-worker.php",
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function waitForWorker(workerPort, child, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestJson({ port: workerPort, path: "/health" });
|
||||
if (response.status === 200 && response.body?.service === "lan-worker") {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw lastError || new Error("LAN worker did not become healthy");
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, agentExited, workerExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
shellySwitchSetSeen: state.shellySwitchSetSeen,
|
||||
shellyStatusSeen: state.shellyStatusSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !agentExited,
|
||||
workerStayedRunningUntilProofComplete: !workerExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
if (!fs.existsSync(options.workerPath)) {
|
||||
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
shellySwitchSetSeen: false,
|
||||
shellyStatusSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const shellyServer = createShellyServer(state);
|
||||
let apiServer = null;
|
||||
let agent = null;
|
||||
let worker = null;
|
||||
let agentStdout = "";
|
||||
let agentStderr = "";
|
||||
let workerStdout = "";
|
||||
let workerStderr = "";
|
||||
let agentExited = false;
|
||||
let workerExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const shellyPort = await listen(shellyServer);
|
||||
const workerPort = await reservePort();
|
||||
const shellyAddress = `127.0.0.1:${shellyPort}`;
|
||||
apiServer = createApiServer(state, brokerPort, shellyAddress);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
worker = spawnWorker({ ...options, workerPort });
|
||||
worker.stdout.on("data", (chunk) => {
|
||||
workerStdout += chunk.toString();
|
||||
});
|
||||
worker.stderr.on("data", (chunk) => {
|
||||
workerStderr += chunk.toString();
|
||||
});
|
||||
worker.once("exit", () => {
|
||||
workerExited = true;
|
||||
});
|
||||
await waitForWorker(workerPort, worker, 5000);
|
||||
|
||||
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
agent.stdout.on("data", (chunk) => {
|
||||
agentStdout += chunk.toString();
|
||||
});
|
||||
agent.stderr.on("data", (chunk) => {
|
||||
agentStderr += chunk.toString();
|
||||
});
|
||||
agent.once("exit", () => {
|
||||
agentExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
|
||||
if (
|
||||
state.brokerHandshakeSeen &&
|
||||
state.commandPollSeen &&
|
||||
state.shellySwitchSetSeen &&
|
||||
state.shellyStatusSeen &&
|
||||
state.resultSeen
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, agentExited, workerExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.shellySwitchSetSeen ||
|
||||
!state.shellyStatusSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.agentStdout = agentStdout.slice(-3000);
|
||||
error.agentStderr = agentStderr.slice(-3000);
|
||||
error.workerStdout = workerStdout.slice(-3000);
|
||||
error.workerStderr = workerStderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
workerPath: options.workerPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (agent) {
|
||||
await stopChild(agent);
|
||||
}
|
||||
if (worker) {
|
||||
await stopChild(worker);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(shellyServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.agentStdout) {
|
||||
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
|
||||
}
|
||||
if (error.agentStderr) {
|
||||
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
|
||||
}
|
||||
if (error.workerStdout) {
|
||||
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
|
||||
}
|
||||
if (error.workerStderr) {
|
||||
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+112
-39
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
const websocketUrl = new URL(String(rawUrl));
|
||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = ciBrokerPort;
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||
websocketUrl.hostname = "edge-broker";
|
||||
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
|
||||
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = apiUrl.port;
|
||||
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
|
||||
}
|
||||
|
||||
if (isLocalHost(websocketUrl.hostname)) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
|
||||
const diagnostics = {};
|
||||
|
||||
if (gatewayId !== null && gatewayId > 0 && authToken) {
|
||||
try {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
diagnostics.gateway = {
|
||||
status: detail?.data?.status ?? null,
|
||||
channelStatus: detail?.data?.channel_status ?? null,
|
||||
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
|
||||
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
|
||||
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
|
||||
allowFailure: true,
|
||||
});
|
||||
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
|
||||
} catch (error) {
|
||||
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = await resolveRootDir(scriptPath);
|
||||
@@ -669,22 +727,35 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
try {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await readGatewayDiagnostics({
|
||||
baseUrl,
|
||||
authToken,
|
||||
gatewayId,
|
||||
containerName,
|
||||
});
|
||||
throw new Error([
|
||||
error instanceof Error ? error.message : String(error),
|
||||
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
@@ -707,11 +778,14 @@ async function main() {
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
||||
assert.equal(
|
||||
Boolean(readyMessage?.connected),
|
||||
true,
|
||||
"Gateway stream became ready before the broker reported the gateway as connected."
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => (
|
||||
message?.type === "gateway.stream.ready" && message?.connected === true
|
||||
) || (
|
||||
message?.type === "presence.changed" && message?.status === "connected"
|
||||
),
|
||||
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
@@ -739,10 +813,24 @@ async function main() {
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
try {
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED"
|
||||
|| streamMessages.some((message) => (
|
||||
message?.type === "task.updated"
|
||||
&& Number(message?.operationId || 0) === operationId
|
||||
&& message?.operation?.status === "COMPLETED"
|
||||
));
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot = null;
|
||||
@@ -765,21 +853,6 @@ async function main() {
|
||||
throw new Error(diagnostic);
|
||||
}
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
|
||||
+43
-6
@@ -18,6 +18,7 @@ cd "$repo_root"
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
@@ -68,6 +69,33 @@ retry_command() {
|
||||
done
|
||||
}
|
||||
|
||||
composer_install() {
|
||||
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
|
||||
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
|
||||
|
||||
if retry_command "$dist_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
|
||||
retry_command "$source_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||
}
|
||||
|
||||
configure_ci_docker_subnet() {
|
||||
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -87,7 +115,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||
configure_ci_docker_subnet
|
||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
@@ -110,10 +139,18 @@ tar \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
|
||||
export const REQUIRED_MANIFEST_ARTIFACTS = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
];
|
||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||
"/edge-agent/install-token/status",
|
||||
"/edge-agent/artifacts/manifest.json",
|
||||
"report_install_status",
|
||||
'begin_install_phase "VERIFY_TOKEN"',
|
||||
'begin_install_phase "VERIFY_ARTIFACTS"',
|
||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||
'report_install_status "FAILED"',
|
||||
"verify_manifest_artifact",
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
];
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
|
||||
name: "Ping",
|
||||
url: `${normalizedBaseUrl}/ping`,
|
||||
},
|
||||
{
|
||||
name: "Artifact manifest",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
||||
artifactName: "manifest.json",
|
||||
},
|
||||
{
|
||||
name: "Agent PHP artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||
artifactName: "agent.php",
|
||||
},
|
||||
{
|
||||
name: "Service unit artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||
artifactName: "truckwash-edge-agent.service",
|
||||
},
|
||||
{
|
||||
name: "Installer script",
|
||||
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
|
||||
];
|
||||
}
|
||||
|
||||
export function validateArtifactManifestBody(body) {
|
||||
const manifest = JSON.parse(String(body || ""));
|
||||
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
|
||||
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
|
||||
}
|
||||
if (!Array.isArray(manifest.artifacts)) {
|
||||
throw new Error("Artifact manifest is missing artifacts.");
|
||||
}
|
||||
|
||||
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
|
||||
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
|
||||
if (missingArtifacts.length) {
|
||||
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
|
||||
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (sha256 !== artifact.sha256) {
|
||||
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
|
||||
}
|
||||
if (buffer.length !== artifact.bytes) {
|
||||
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function validateInstallerScriptBody(body) {
|
||||
const source = String(body || "");
|
||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
|
||||
const checks = buildChecks(baseUrl, installToken);
|
||||
const results = [];
|
||||
let artifactManifest = null;
|
||||
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||
const response = await fetch(check.url);
|
||||
const body = await response.text();
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
const result = {
|
||||
...check,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
bodyPreview: previewBody(body),
|
||||
bodyPreview: previewBody(body.toString("utf8")),
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (check.name === "Artifact manifest") {
|
||||
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
|
||||
result.version = artifactManifest.version;
|
||||
result.artifactCount = artifactManifest.artifacts.length;
|
||||
}
|
||||
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
|
||||
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
|
||||
}
|
||||
if (check.name === "Installer script") {
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_STAGING_BASE_URL,
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||
buildChecks,
|
||||
normalizeBaseUrl,
|
||||
parseArgs,
|
||||
validateArtifactBodyAgainstManifest,
|
||||
validateArtifactManifestBody,
|
||||
validateInstallerScriptBody,
|
||||
} from "./staging-edge-gateway-smoke.mjs";
|
||||
|
||||
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
|
||||
|
||||
assert.deepEqual(checks.map((check) => check.url), [
|
||||
"https://api.truckwash.io:4433/ping",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validateArtifactManifestBody requires v3 install artifacts", () => {
|
||||
const artifacts = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
|
||||
|
||||
const manifest = validateArtifactManifestBody(JSON.stringify({
|
||||
version: EXPECTED_INSTALL_VERSION,
|
||||
artifacts,
|
||||
}));
|
||||
|
||||
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
|
||||
});
|
||||
|
||||
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
|
||||
const body = Buffer.from("hello");
|
||||
const manifest = {
|
||||
artifacts: [{
|
||||
name: "agent.php",
|
||||
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
bytes: body.length,
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||
const script = `
|
||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
|
||||
report_install_status "FAILED"
|
||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||
verify_manifest_artifact
|
||||
${EXPECTED_INSTALL_VERSION}
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
||||
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||
@@ -409,14 +409,20 @@ async function startContainer({
|
||||
});
|
||||
|
||||
if (copyConfig) {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
||||
try {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
configFilePath,
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand("docker", ["start", containerName], {
|
||||
cwd: rootDir,
|
||||
|
||||
Vendored
+58
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||
const commands = Array.isArray(command?.payload?.commands)
|
||||
? command.payload.commands
|
||||
: Array.isArray(command?.commands)
|
||||
? command.commands
|
||||
: [];
|
||||
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||
|
||||
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||
const target = String(entry.target || entry.relay || "");
|
||||
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||
try {
|
||||
const payload = await handler(entry, fetchImpl);
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||
case "SET_RELAY_STATE":
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "BATCH_RELAY_STATUS":
|
||||
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||
case "BATCH_SET_RELAY_STATE":
|
||||
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
|
||||
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||
const fakeFetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("10.1.0.31")) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("relay offline");
|
||||
};
|
||||
|
||||
const result = await handleAgentCommand({
|
||||
commandType: "BATCH_SET_RELAY_STATE",
|
||||
payload: {
|
||||
batch_id: "batch-1",
|
||||
commands: [
|
||||
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||
],
|
||||
},
|
||||
}, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.equal(result.batch_id, "batch-1");
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].target, "MACHINE");
|
||||
assert.equal(result.results[1].ok, false);
|
||||
assert.match(result.results[1].error, /relay offline/);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
|
||||
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
const ingestMachineSignal =
|
||||
options.ingestMachineSignal ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MACHINE_SIGNAL") {
|
||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
|
||||
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||
const machineSignals = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
ingestMachineSignal: async (gatewayId, payload) => {
|
||||
machineSignals.push({ gatewayId, payload });
|
||||
return { recorded: true, lane_id: payload.lane_id };
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "MACHINE_SIGNAL",
|
||||
payload: {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||
assert.equal(machineSignals[0].gatewayId, "701");
|
||||
assert.deepEqual(machineSignals[0].payload, {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -133,8 +133,14 @@ class attachments implements attachments_i
|
||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||
{
|
||||
$options = $this->normalizeAttachmentOptions($options);
|
||||
$rawType = trim($type, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$rawType,
|
||||
'`' . $rawType . '`',
|
||||
]));
|
||||
|
||||
return (new object_attachments_o())->getFieldsWhereIn([
|
||||
'object_type' => $type,
|
||||
'object_type' => $objectTypes,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -121,6 +121,16 @@ class coolify_api_client
|
||||
]);
|
||||
}
|
||||
|
||||
public function listApplicationEnvs(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
|
||||
}
|
||||
|
||||
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
@@ -159,6 +169,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function deleteService(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
|
||||
@@ -6,6 +6,7 @@ class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
@@ -25,6 +26,9 @@ class cors_policy
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
@@ -125,7 +129,9 @@ class cors_policy
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Timing-Allow-Origin' => $origin,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||
);
|
||||
|
||||
return $collected_order_invoices->asArray();
|
||||
$result = $collected_order_invoices->asArray();
|
||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||
if ($transfer_metrics !== null) {
|
||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||
{
|
||||
if (self::isFakeDeliveryEnabled()) {
|
||||
self::$fake_deliveries[] = [
|
||||
self::recordFakeDelivery([
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
];
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
public static function resetFakeDeliveries(): void
|
||||
{
|
||||
self::$fake_deliveries = [];
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path !== null && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncFakeDeliveries(): void
|
||||
{
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null || !is_file($path)) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
foreach ($lines as $line) {
|
||||
$delivery = json_decode($line, true);
|
||||
if (is_array($delivery)) {
|
||||
$deliveries[] = $delivery;
|
||||
}
|
||||
}
|
||||
|
||||
self::$fake_deliveries = $deliveries;
|
||||
}
|
||||
|
||||
private static function recordFakeDelivery(array $delivery): void
|
||||
{
|
||||
self::$fake_deliveries[] = $delivery;
|
||||
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function getFakeDeliveriesPath(): ?string
|
||||
{
|
||||
if (!self::isFakeDeliveryEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
||||
if ($configuredPath !== '') {
|
||||
return $configuredPath;
|
||||
}
|
||||
|
||||
if (getenv('RUN_API_TESTS') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR
|
||||
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
||||
}
|
||||
|
||||
private static function isFakeDeliveryEnabled(): bool
|
||||
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$this->attachments
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
if (!$customer->exists()) {
|
||||
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
||||
}
|
||||
|
||||
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
|
||||
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
|
||||
$safeCustomerNumber = (int)$customer->customer_number->value();
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
$message = "
|
||||
<p>A new customer has registered on truckwash.io.</p>
|
||||
<p>
|
||||
<strong>Customer number:</strong> $safeCustomerNumber<br>
|
||||
<strong>Customer name:</strong> $safeCustomerName
|
||||
</p>
|
||||
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
|
||||
";
|
||||
|
||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
||||
if ($recipientEmail === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recipientName = trim((string)($recipient['display_name'] ?? ''));
|
||||
if ($recipientName === '') {
|
||||
$recipientName = $recipientEmail;
|
||||
}
|
||||
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return match ($endpoint) {
|
||||
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
||||
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
|
||||
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
|
||||
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
|
||||
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
||||
};
|
||||
}
|
||||
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchGetStates(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
|
||||
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
|
||||
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchSetSwitch(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? []) as $entry) {
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
'on' => (bool)($entry['on'] ?? false),
|
||||
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $status
|
||||
* @return array<string,mixed>
|
||||
|
||||
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
||||
|
||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
{
|
||||
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
||||
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
||||
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
||||
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
||||
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
||||
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
||||
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
||||
private const RESULT_CACHE_TTL_SECONDS = 10;
|
||||
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
||||
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
||||
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
||||
/**
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $last_timings = [];
|
||||
|
||||
/**
|
||||
* The configuration of the module
|
||||
* @var licenseplaterecognizer_c
|
||||
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
* API URL
|
||||
* @var string
|
||||
*/
|
||||
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||
private string $api_url;
|
||||
|
||||
/**
|
||||
* @var array{enabled: bool, api_key: string}|null
|
||||
*/
|
||||
private ?array $runtime_config = null;
|
||||
|
||||
public function __construct()
|
||||
/**
|
||||
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
||||
*/
|
||||
private static ?array $runtime_config_cache = null;
|
||||
|
||||
public function __construct(bool $load_config = true, ?string $api_url = null)
|
||||
{
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
||||
|
||||
if ($load_config) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->runtimeConfig()['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
}
|
||||
@@ -45,54 +74,516 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array
|
||||
{
|
||||
$image_processor = new image_processor();
|
||||
|
||||
//ADD PARAMETER IN REQUEST LIKE regions
|
||||
$data = array(
|
||||
'upload' => $base64_image,
|
||||
//'regions' => 'dk' // Optional
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare new cURL resource
|
||||
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
),
|
||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
||||
);
|
||||
}
|
||||
|
||||
// Set HTTP Header for POST request
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_data = json_decode($result, true);
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
'raw_response' => $response_data,
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
||||
{
|
||||
$started_at = microtime(true);
|
||||
$this->last_timings = [];
|
||||
$result_cache = null;
|
||||
$result_cache_key = null;
|
||||
|
||||
try {
|
||||
$config_started_at = microtime(true);
|
||||
$runtime_config = $this->runtimeConfig();
|
||||
if (!$runtime_config['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
$api_key = $runtime_config['api_key'];
|
||||
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
||||
|
||||
if ($result_cache_key_factory !== null) {
|
||||
$cache_started_at = microtime(true);
|
||||
try {
|
||||
$result_cache = $this->resultCacheStore();
|
||||
if ($result_cache !== null) {
|
||||
$result_cache_key = $result_cache_key_factory();
|
||||
if ($result_cache_key !== null) {
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $cached_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->last_timings['cache_miss'] = 1;
|
||||
} finally {
|
||||
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
||||
}
|
||||
}
|
||||
|
||||
$payload_started_at = microtime(true);
|
||||
$data = $payload_factory();
|
||||
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
||||
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
if (!$ch instanceof \CurlHandle) {
|
||||
throw new Exception('Error initializing API request.');
|
||||
}
|
||||
|
||||
$curl_options = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
||||
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Token " . $api_key,
|
||||
'Expect:',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
||||
}
|
||||
curl_setopt_array($ch, $curl_options);
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$upstream_started_at = microtime(true);
|
||||
$result = curl_exec($ch);
|
||||
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
||||
$this->recordCurlTimings($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
|
||||
$parse_started_at = microtime(true);
|
||||
$response_data = json_decode($result, true);
|
||||
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
||||
$this->recordResponseTimings($response_data);
|
||||
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
$recognized_result = [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
];
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
'success' => false,
|
||||
'message' => 'No license plate detected.',
|
||||
'raw_response' => $response_data,
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
return connection_aborted() ? 1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
public function getLastTimings(): array
|
||||
{
|
||||
return $this->last_timings;
|
||||
}
|
||||
|
||||
private function elapsedMs(float $started_at): float
|
||||
{
|
||||
return (microtime(true) - $started_at) * 1000;
|
||||
}
|
||||
|
||||
private static function configuredApiUrl(): string
|
||||
{
|
||||
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
||||
if ($configured === false || trim((string)$configured) === '') {
|
||||
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return (string)$configured;
|
||||
}
|
||||
|
||||
private static function normalizeApiUrl(string $api_url): string
|
||||
{
|
||||
$api_url = trim($api_url);
|
||||
if ($api_url === '') {
|
||||
return self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return rtrim($api_url, '/');
|
||||
}
|
||||
|
||||
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
||||
{
|
||||
$mapping = [
|
||||
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
||||
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
||||
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
||||
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
||||
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
||||
CURLINFO_TOTAL_TIME => 'upstream_total',
|
||||
];
|
||||
|
||||
foreach ($mapping as $curl_info_option => $timing_key) {
|
||||
$value = curl_getinfo($curl_handle, $curl_info_option);
|
||||
if (!is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private function recordResponseTimings(mixed $response_data): void
|
||||
{
|
||||
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
||||
$image_data = base64_decode((string)$matches[1], true);
|
||||
if (is_string($image_data)) {
|
||||
return $this->buildResultCacheKeyFromBytes($image_data);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->buildResultCacheKeyFromBytes($base64_image);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromBytes(string $image_data): string
|
||||
{
|
||||
$context = hash_init('sha256');
|
||||
hash_update($context, $this->resultCacheContext());
|
||||
hash_update($context, "\0");
|
||||
hash_update($context, $image_data);
|
||||
|
||||
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
||||
}
|
||||
|
||||
private function resultCacheContext(): string
|
||||
{
|
||||
return self::RESULT_CACHE_CONTEXT;
|
||||
}
|
||||
|
||||
protected function resultCacheStore(): ?object
|
||||
{
|
||||
return $this->runtimeConfigCacheStore();
|
||||
}
|
||||
|
||||
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get($key);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayload(string $base64_image): array
|
||||
{
|
||||
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
||||
{
|
||||
return [
|
||||
'upload' => $upload,
|
||||
'config' => self::PLATE_READER_CONFIG_JSON,
|
||||
'regions' => self::PLATE_READER_REGIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
||||
return $base64_image;
|
||||
}
|
||||
|
||||
$image_data = base64_decode((string)$matches[2], true);
|
||||
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
||||
return (string)$matches[2];
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
||||
}
|
||||
|
||||
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
||||
{
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!class_exists(\CURLStringFile::class)) {
|
||||
return $image_data;
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
||||
{
|
||||
$image_path = trim($image_path);
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
||||
throw new Exception('Image upload file is invalid.');
|
||||
}
|
||||
|
||||
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
||||
}
|
||||
|
||||
protected function runtimeConfig(): array
|
||||
{
|
||||
if ($this->runtime_config !== null) {
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
$cached_config = self::getSharedRuntimeConfigCache();
|
||||
if ($cached_config !== null) {
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
$cached_config = $this->readRuntimeConfigCacheStore();
|
||||
if ($cached_config !== null) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $cached_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
}
|
||||
|
||||
$values = $this->readRuntimeModuleConfig();
|
||||
|
||||
$this->runtime_config = [
|
||||
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
||||
'api_key' => (string)($values['api_key'] ?? ''),
|
||||
];
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $this->runtime_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
||||
}
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
||||
{
|
||||
return static::class === self::class;
|
||||
}
|
||||
|
||||
private static function getSharedRuntimeConfigCache(): ?array
|
||||
{
|
||||
if (self::$runtime_config_cache === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
||||
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
||||
self::$runtime_config_cache = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$runtime_config_cache['values'];
|
||||
}
|
||||
|
||||
protected function runtimeConfigCacheStore(): ?object
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
private function readRuntimeConfigCacheStore(): ?array
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
||||
'api_key' => (string)$decoded['api_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, api_key: string} $config
|
||||
*/
|
||||
private function writeRuntimeConfigCacheStore(array $config): void
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner config cache is best-effort; DB remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
private function parseModuleConfigBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return strtolower(trim((string)$value)) === 'true';
|
||||
}
|
||||
|
||||
protected function readRuntimeModuleConfig(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($db instanceof db) {
|
||||
$module = $db->escape_string('licenseplaterecognizer');
|
||||
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
||||
$values = [];
|
||||
|
||||
if ($result instanceof \mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$variable = (string)($row['variable'] ?? '');
|
||||
if ($variable !== '') {
|
||||
$values[$variable] = (string)($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
if (!isset($this->config)) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
||||
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||
@@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info
|
||||
{
|
||||
// Require the module to be enabled
|
||||
$this->requireModuleEnabled();
|
||||
$api_key = $this->runtimeConfig()['api_key'];
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, array(
|
||||
CURLOPT_URL => $this->api_url . '/info/',
|
||||
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||
),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token ' . $api_key,
|
||||
],
|
||||
));
|
||||
$response = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
@@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
}
|
||||
return new licenseplaterecognizer_info($response_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_exception extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly int $statusCode = 400,
|
||||
private readonly ?array $payload = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function payload(): array|string
|
||||
{
|
||||
return $this->payload ?? $this->getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`managed_group_id` INT NOT NULL,
|
||||
`role_key` VARCHAR(64) NOT NULL,
|
||||
`department_ids` LONGTEXT NOT NULL,
|
||||
`created_by_user_id` INT NOT NULL,
|
||||
`updated_by_user_id` INT NULL,
|
||||
`deactivated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
$result = self::getS3Client()->getObject([
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
|
||||
@@ -1434,6 +1434,8 @@ class release_manager
|
||||
];
|
||||
}
|
||||
|
||||
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
|
||||
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
|
||||
$checked = [];
|
||||
try {
|
||||
foreach ($gateInput['api_ping_paths'] as $path) {
|
||||
@@ -1442,9 +1444,19 @@ class release_manager
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
throw new RuntimeException(sprintf('%s returned success=false.', $path));
|
||||
}
|
||||
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
|
||||
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'%s returned commit %s, expected %s.',
|
||||
$path,
|
||||
$actualCommit !== '' ? $actualCommit : 'unknown',
|
||||
$expectedCommit
|
||||
));
|
||||
}
|
||||
$checked[] = [
|
||||
'path' => $path,
|
||||
'status' => $json['status'],
|
||||
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
@@ -1471,10 +1483,24 @@ class release_manager
|
||||
'context' => [
|
||||
'api_base_url' => $apiBaseUrl,
|
||||
'checked' => $checked,
|
||||
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function releaseGateApiPayloadCommitSha(array $payload): string
|
||||
{
|
||||
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
|
||||
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
|
||||
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
|
||||
if ($commit !== '') {
|
||||
return $commit;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function releaseGateFetchJson(string $baseUrl, string $path): array
|
||||
{
|
||||
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
||||
@@ -2066,11 +2092,7 @@ class release_manager
|
||||
|
||||
$eventId = (int)$event['id'];
|
||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => 'Automatic container update is already being processed for this commit.',
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
];
|
||||
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2152,6 +2174,67 @@ class release_manager
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
|
||||
{
|
||||
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
|
||||
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
|
||||
$deadline = time() + $timeout;
|
||||
$attempts = 0;
|
||||
$lastStatus = 'unknown';
|
||||
|
||||
do {
|
||||
$attempts++;
|
||||
$event = $this->releaseAutoSyncEventById($eventId);
|
||||
if ($event === null) {
|
||||
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
|
||||
}
|
||||
|
||||
$lastStatus = (string)($event['status'] ?? 'unknown');
|
||||
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
|
||||
$deployment = null;
|
||||
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
|
||||
if ($deploymentId !== null) {
|
||||
$deployment = $this->getDeployment($deploymentId);
|
||||
}
|
||||
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
|
||||
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
|
||||
return [
|
||||
'step_status' => 'passed',
|
||||
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
||||
'deployment' => $this->publicDeployment($deployment),
|
||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||
'attempts' => $attempts,
|
||||
];
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
|
||||
$eventId,
|
||||
strtoupper($app),
|
||||
$commitSha
|
||||
));
|
||||
}
|
||||
|
||||
if ($lastStatus === 'failed') {
|
||||
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
|
||||
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
|
||||
}
|
||||
|
||||
if (time() >= $deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
sleep($pollInterval);
|
||||
} while (true);
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
|
||||
$eventId,
|
||||
$timeout,
|
||||
$lastStatus
|
||||
));
|
||||
}
|
||||
|
||||
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||
{
|
||||
$channelId = (int)$input['channel_id'];
|
||||
@@ -6232,6 +6315,7 @@ class release_manager
|
||||
}
|
||||
|
||||
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
||||
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
|
||||
return [
|
||||
'service_uuid' => $serviceUuid,
|
||||
'resource_type' => $resourceType,
|
||||
@@ -6241,9 +6325,76 @@ class release_manager
|
||||
'updated' => self::redactPayload($update ?? []),
|
||||
'runtime_env' => $runtimeEnvUpdate,
|
||||
'deployment' => self::redactPayload($deployment),
|
||||
'previous_applications' => self::redactPayload($previousApplications),
|
||||
];
|
||||
}
|
||||
|
||||
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
|
||||
{
|
||||
$stopped = [];
|
||||
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
|
||||
try {
|
||||
$stopped[] = [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'stop_requested',
|
||||
'result' => $client->stopApplication($uuid),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$stopped[] = [
|
||||
'uuid' => $uuid,
|
||||
'status' => 'warning',
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $stopped;
|
||||
}
|
||||
|
||||
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ([
|
||||
'coolify_previous_application_uuid',
|
||||
'coolify_previous_artifact_app_uuid',
|
||||
'coolify_previous_artifact_application_uuid',
|
||||
'previous_application_uuid',
|
||||
'previous_app_uuid',
|
||||
] as $key) {
|
||||
if (is_scalar($context[$key] ?? null)) {
|
||||
$values[] = (string)$context[$key];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'coolify_previous_application_uuids',
|
||||
'coolify_previous_artifact_app_uuids',
|
||||
'previous_application_uuids',
|
||||
'previous_app_uuids',
|
||||
] as $key) {
|
||||
if (!is_array($context[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($context[$key] as $value) {
|
||||
if (is_scalar($value)) {
|
||||
$values[] = (string)$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$activeUuid = trim($activeUuid);
|
||||
$uuids = [];
|
||||
foreach ($values as $value) {
|
||||
$uuid = trim((string)$value);
|
||||
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
|
||||
continue;
|
||||
}
|
||||
$uuids[] = $uuid;
|
||||
}
|
||||
|
||||
return $uuids;
|
||||
}
|
||||
|
||||
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
||||
{
|
||||
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
||||
@@ -6252,6 +6403,7 @@ class release_manager
|
||||
}
|
||||
|
||||
if ($resourceType === 'application') {
|
||||
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
|
||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||
} else {
|
||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||
@@ -6264,12 +6416,45 @@ class release_manager
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
|
||||
{
|
||||
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
|
||||
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
|
||||
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->deleteApplicationEnv($resourceUuid, $uuid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||
{
|
||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||
$env = $contextEnv;
|
||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||
|
||||
if ($app !== 'api') {
|
||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||
return $env;
|
||||
}
|
||||
|
||||
@@ -6291,21 +6476,37 @@ class release_manager
|
||||
$this->appendRuntimeEnvValue($env, $key, $value);
|
||||
}
|
||||
|
||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||
if ($deploymentCommitSha !== '') {
|
||||
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
|
||||
if (!array_key_exists($key, $contextEnv)) {
|
||||
$env[$key] = $deploymentCommitSha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$env = array_replace($env, $contextEnv);
|
||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||
}
|
||||
|
||||
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
|
||||
{
|
||||
if ($deploymentCommitSha === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
|
||||
$env[$key] = $deploymentCommitSha;
|
||||
}
|
||||
}
|
||||
|
||||
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
|
||||
{
|
||||
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
|
||||
if ($app === 'frontend') {
|
||||
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
|
||||
}
|
||||
if ($app === 'api') {
|
||||
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function releaseCoolifyContextEnv(array $context): array
|
||||
{
|
||||
$env = [];
|
||||
@@ -7013,6 +7214,13 @@ class release_manager
|
||||
|
||||
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
||||
{
|
||||
foreach (['commit_sha', 'commit'] as $key) {
|
||||
$value = trim((string)($target[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'coolify_git_commit_sha',
|
||||
'git_commit_sha',
|
||||
@@ -8928,7 +9136,7 @@ class release_manager
|
||||
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
||||
return $payload;
|
||||
}
|
||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
|
||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
|
||||
if (is_array($payload[$key] ?? null)) {
|
||||
return $this->payloadRows($payload[$key]);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class router
|
||||
}
|
||||
// Regex
|
||||
$route = str_replace('/', '\/', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
if (preg_match('/^' . $route . '$/', $this->url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class selfserve_schema_bootstrap
|
||||
session_id INT NOT NULL,
|
||||
task_id INT NULL,
|
||||
task_text VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
description TEXT NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
dynamic_image_id INT NULL,
|
||||
@@ -197,6 +197,18 @@ class selfserve_schema_bootstrap
|
||||
'gate_ref_id',
|
||||
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'department_selfserve_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'selfserve_wash_session_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_session_tasks',
|
||||
'dynamic_images_vehicle_type',
|
||||
@@ -239,4 +251,49 @@ class selfserve_schema_bootstrap
|
||||
}
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $acceptedDataTypes
|
||||
*/
|
||||
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$columnInfo = self::columnInfo($table, $column);
|
||||
if ($columnInfo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
|
||||
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
|
||||
if (in_array($dataType, $acceptedDataTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function columnInfo(string $table, string $column): ?array
|
||||
{
|
||||
global $db;
|
||||
$table = $db->escape_string($table);
|
||||
$column = $db->escape_string($column);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'
|
||||
LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return null;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class shelly implements shelly_i
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
|
||||
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
|
||||
/**
|
||||
* @var array<int,array<string,mixed>>
|
||||
*/
|
||||
@@ -178,6 +180,9 @@ class shelly implements shelly_i
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
// Execute the request
|
||||
$response = curl_exec($ch);
|
||||
// Get the status code
|
||||
@@ -224,6 +229,9 @@ class shelly implements shelly_i
|
||||
);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
|
||||
use interfaces\notification_i;
|
||||
use objects\departments_o;
|
||||
use objects\users_o;
|
||||
use slack\slack_c;
|
||||
use traits\notification_t;
|
||||
|
||||
require_once WD . '/modules/slack/slack_c.php';
|
||||
|
||||
class slack implements notification_i
|
||||
{
|
||||
use notification_t;
|
||||
|
||||
private ?slack_c $config = null;
|
||||
|
||||
public function getConfig(): slack_c
|
||||
{
|
||||
if ($this->config === null) {
|
||||
$this->config = new slack_c();
|
||||
}
|
||||
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
@@ -122,7 +134,7 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
public function send_message(string $string, string $module = null): void
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
// Format the message if a module is provided
|
||||
@@ -132,4 +144,189 @@ class slack implements notification_i
|
||||
// Send the message to the slack webhook
|
||||
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
|
||||
}
|
||||
}
|
||||
|
||||
public function send_customer_registration_notification(int $customer_number): self
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
self::add_log(self::send_webhook_message(
|
||||
$this->format_customer_registration($customer_number),
|
||||
$webhook
|
||||
));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_customer_registration_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_customer_registration_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack customer registration test webhook sent successfully.'
|
||||
: 'Slack customer registration test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_internal_department_goal_progress_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_internal_department_goal_progress_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack internal department goal progress webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_internal_department_goal_progress_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack internal department goal progress test webhook sent successfully.'
|
||||
: 'Slack internal department goal progress test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function get_customer_registration_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function get_internal_department_ids(): array
|
||||
{
|
||||
return $this->getConfig()->internal_department_ids->getDepartmentIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $department_ids
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
|
||||
{
|
||||
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
|
||||
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
|
||||
|
||||
return $this->get_internal_department_goal_progress_config();
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_config(): array
|
||||
{
|
||||
$departments = (new departments_o())->getFieldsWhere(
|
||||
[
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
],
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'order_priority',
|
||||
]
|
||||
);
|
||||
|
||||
usort($departments, static function (array $a, array $b): int {
|
||||
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
|
||||
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||
});
|
||||
|
||||
return [
|
||||
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
|
||||
'internal_department_ids' => $this->get_internal_department_ids(),
|
||||
'departments' => array_map(static function (array $department): array {
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
}, $departments),
|
||||
];
|
||||
}
|
||||
|
||||
public function is_webhook_send_successful(string $result): bool
|
||||
{
|
||||
return !str_starts_with($result, 'Failed to send message:');
|
||||
}
|
||||
|
||||
public function format_customer_registration(int $customer_number): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
$customerName = $customer->exists()
|
||||
? $customer->getCustomerName((int)$customer->customer_number->value())
|
||||
: '';
|
||||
$customerName = trim((string)$customerName);
|
||||
if ($customerName === '') {
|
||||
$customerName = 'Unknown customer';
|
||||
}
|
||||
|
||||
$safeCustomerNumber = (int)$customer_number;
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
|
||||
return "*New customer registered on Truck Wash*\n"
|
||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||
. "Open in Superuser: $customerUrl";
|
||||
}
|
||||
|
||||
public function format_customer_registration_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Customer registration notifications are configured correctly.";
|
||||
}
|
||||
|
||||
public function format_internal_department_goal_progress_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Internal department goal progress notifications are configured correctly.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"scripts": {
|
||||
"test": "composer test:unit",
|
||||
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
|
||||
"static": "@analyse",
|
||||
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
|
||||
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
|
||||
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
|
||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||
"test:api": [
|
||||
|
||||
@@ -19,6 +19,8 @@ use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
use classes\gatewayapi as GatewayAPI;
|
||||
use dynamicimages\images\machine_1;
|
||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
||||
use modules\selfserve\selfserve_c;
|
||||
use goals\classes\goals_criteria;
|
||||
use goals\services\goals_progress_alert_renderer;
|
||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||
@@ -33,6 +35,8 @@ use objects\users_o;
|
||||
use routes\moduleWeatherAPIRoute;
|
||||
|
||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
|
||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
@@ -930,6 +934,7 @@ function buildDynamicImageCacheKey(array $variant): string
|
||||
'current_step' => (int)($variant['current_step'] ?? 0),
|
||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
|
||||
];
|
||||
|
||||
$json = json_encode($cacheParams);
|
||||
@@ -962,16 +967,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
$image->current_step = max(0, $currentStep);
|
||||
$image->only_generate_current_step = $onlyCurrentStep;
|
||||
$image->setup();
|
||||
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
||||
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
|
||||
}
|
||||
|
||||
$dataUri = $image->exportAsBase64('png');
|
||||
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$imageData = base64_decode($matches[1], true);
|
||||
if ($imageData === false) {
|
||||
return null;
|
||||
}
|
||||
return $imageData;
|
||||
return $image->exportBinary('png');
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||
return null;
|
||||
@@ -985,6 +985,19 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfServeDynamicImageSizeModeForCron(): string
|
||||
{
|
||||
try {
|
||||
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
|
||||
} catch (Throwable) {
|
||||
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
|
||||
? $mode
|
||||
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int|string>
|
||||
|
||||
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from a PHP upload temp file without copying it into memory.
|
||||
* @param string $image_path The uploaded image temp-file path.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get usage information about the license plate recognizer module.
|
||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||
* @see licenseplaterecognizer_info
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ interface dynamicimages_image_i
|
||||
*/
|
||||
public function exportAsBase64(?string $format = null, int $quality = 90): string;
|
||||
|
||||
/**
|
||||
* Export the composed image as binary image data.
|
||||
* @param string|null $format Optional target format (e.g. 'png', 'jpeg')
|
||||
* @param int $quality Quality for lossy formats (0-100)
|
||||
* @return string binary image data
|
||||
*/
|
||||
public function exportBinary(?string $format = null, int $quality = 90): string;
|
||||
|
||||
/**
|
||||
* Directly serve the composed image to the client with proper headers.
|
||||
* Convenience wrapper for outputting binary image data.
|
||||
@@ -94,4 +102,4 @@ interface dynamicimages_image_i
|
||||
* @param int $quality Quality for lossy formats (0-100)
|
||||
*/
|
||||
public function servePicture(?string $format = null, int $quality = 90): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ trait dynamicimages_image_t
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resizeToMaxWidth(int $maxWidth): dynamicimages_image_i
|
||||
{
|
||||
$this->assertCanvasInitialized();
|
||||
if ($maxWidth <= 0) {
|
||||
throw new \InvalidArgumentException('Resize max width must be a positive integer.');
|
||||
}
|
||||
if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth));
|
||||
return $this->resize($maxWidth, max(1, $height));
|
||||
}
|
||||
|
||||
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
|
||||
{
|
||||
$this->assertCanvasInitialized();
|
||||
@@ -307,20 +321,8 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
public function exportAsBase64(?string $format = null, int $quality = 90): string
|
||||
{
|
||||
// If a canvas is initialized, export that as PNG by default
|
||||
if ($this->image instanceof \Imagick) {
|
||||
$img = clone $this->image;
|
||||
$img->setImageFormat('png');
|
||||
// Quality influences compression for PNG differently; keep as hint
|
||||
if ($format !== null && strtolower($format) !== 'png') {
|
||||
// For now we only support PNG for composed images as requested
|
||||
}
|
||||
// Strip metadata to reduce size
|
||||
$img->stripImage();
|
||||
$blob = $img->getImageBlob();
|
||||
$img->clear();
|
||||
$img->destroy();
|
||||
return 'data:image/png;base64,' . base64_encode($blob);
|
||||
return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
|
||||
}
|
||||
|
||||
// Fallback: export first asset as-is
|
||||
@@ -341,6 +343,40 @@ trait dynamicimages_image_t
|
||||
return 'data:' . $mime . ';base64,' . base64_encode($data);
|
||||
}
|
||||
|
||||
public function exportBinary(?string $format = null, int $quality = 90): string
|
||||
{
|
||||
// If a canvas is initialized, export that as PNG by default
|
||||
if ($this->image instanceof \Imagick) {
|
||||
$img = clone $this->image;
|
||||
$img->setImageFormat('png');
|
||||
// Quality influences compression for PNG differently; keep as hint
|
||||
if ($format !== null && strtolower($format) !== 'png') {
|
||||
// For now we only support PNG for composed images as requested
|
||||
}
|
||||
// Strip metadata to reduce size
|
||||
$img->stripImage();
|
||||
$blob = $img->getImageBlob();
|
||||
$img->clear();
|
||||
$img->destroy();
|
||||
return $blob;
|
||||
}
|
||||
|
||||
// Fallback: export first asset as-is
|
||||
if (empty($this->assets)) {
|
||||
throw new \RuntimeException('No assets available to export.');
|
||||
}
|
||||
$asset = $this->assets[0];
|
||||
$path = $asset->getPath();
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException('Asset is not readable: ' . $path);
|
||||
}
|
||||
$data = file_get_contents($path);
|
||||
if ($data === false) {
|
||||
throw new \RuntimeException('Failed to read asset: ' . $path);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getAsset(string $asset_name): ?dynamicimages_asset
|
||||
{
|
||||
foreach ($this->assets as $asset) {
|
||||
@@ -357,22 +393,18 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
public function outputImage(?string $format = null, int $quality = 90): void
|
||||
{
|
||||
$dataUri = $this->exportAsBase64($format, $quality);
|
||||
// Extract mime type and base64 data
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
$base64Data = $matches[2];
|
||||
// Decode base64 data
|
||||
$imageData = base64_decode($base64Data);
|
||||
if ($imageData !== false) {
|
||||
// Send appropriate headers
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
// Output the image data
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
$mimeType = 'image/png';
|
||||
if (!$this->image instanceof \Imagick && !empty($this->assets)) {
|
||||
$asset = $this->assets[0];
|
||||
$path = $asset->getPath();
|
||||
$imgInfo = is_readable($path) ? @getimagesize($path) : false;
|
||||
$mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
|
||||
}
|
||||
$imageData = $this->exportBinary($format, $quality);
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,4 +414,4 @@ trait dynamicimages_image_t
|
||||
{
|
||||
$this->outputImage($format, $quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
||||
{
|
||||
$this->add_orders($invoiceDraftId, [$order], $currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add many orders to a draft invoice and flush their lines in batches.
|
||||
*
|
||||
* @param orders_o[] $orders
|
||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
||||
$orders_with_invoice_lines = 0;
|
||||
|
||||
foreach ( $orders as $order ) {
|
||||
if (!$order instanceof orders_o) {
|
||||
throw new Exception('Order payload must contain orders_o instances');
|
||||
}
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orders_with_invoice_lines++;
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
// Save the draft invoice lines
|
||||
$draftInvoice->addLines();
|
||||
}
|
||||
|
||||
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
|
||||
|
||||
return [
|
||||
'order_count' => count($orders),
|
||||
'orders_with_invoice_lines' => $orders_with_invoice_lines,
|
||||
...$metrics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
|
||||
$draft_invoice->addLines();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use objects\orders_o;
|
||||
|
||||
class economic_invoice_draft
|
||||
{
|
||||
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* The Economic draftInvoiceNumber
|
||||
* @var int $draft_invoice_number
|
||||
@@ -110,13 +112,55 @@ class economic_invoice_draft
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the lines to the draft invoice
|
||||
* @return void
|
||||
* Add the lines to the draft invoice.
|
||||
*/
|
||||
public function addLines(): void
|
||||
{
|
||||
$this->flushLinesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add queued draft lines using chunked requests.
|
||||
*
|
||||
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
*/
|
||||
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
||||
{
|
||||
$lines = array_values($this->draft_lines);
|
||||
$line_count = count($lines);
|
||||
if ($line_count === 0) {
|
||||
return [
|
||||
'line_count' => 0,
|
||||
'batch_count' => 0,
|
||||
'batch_sizes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$batch_size = max(1, $batch_size);
|
||||
$batch_sizes = [];
|
||||
foreach (array_chunk($lines, $batch_size) as $batch) {
|
||||
$this->sendDraftLines($batch);
|
||||
$batch_sizes[] = count($batch);
|
||||
}
|
||||
|
||||
$this->draft_lines = [];
|
||||
|
||||
return [
|
||||
'line_count' => $line_count,
|
||||
'batch_count' => count($batch_sizes),
|
||||
'batch_sizes' => $batch_sizes,
|
||||
];
|
||||
}
|
||||
|
||||
public function pendingLineCount(): int
|
||||
{
|
||||
return count($this->draft_lines);
|
||||
}
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
$economic = new economic();
|
||||
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
||||
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-3
@@ -6,6 +6,7 @@ use Exception;
|
||||
|
||||
class edge_gateway_agent_artifact_locator
|
||||
{
|
||||
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
|
||||
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
|
||||
|
||||
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||
|
||||
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||
if ($mountedArtifactDirectory !== null) {
|
||||
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
||||
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
|
||||
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||
}
|
||||
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
|
||||
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function edgeAgentBuildDirectories(string $basePath): array
|
||||
{
|
||||
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories = [
|
||||
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
];
|
||||
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_map(
|
||||
static fn(string $directory): string => self::normalizePath($directory),
|
||||
$directories
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function legacyPhpAgentDirectories(string $basePath): array
|
||||
{
|
||||
return [
|
||||
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function mountedArtifactDirectory(): ?string
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/') {
|
||||
|
||||
@@ -7,6 +7,7 @@ use Exception;
|
||||
class edge_gateway_install_service
|
||||
{
|
||||
private const ARTIFACTS = [
|
||||
'manifest.json' => 'application/json; charset=utf-8',
|
||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
@@ -26,7 +27,7 @@ class edge_gateway_install_service
|
||||
|
||||
public function buildInstallScript(string $plainToken): string
|
||||
{
|
||||
return $this->manager()->buildInstallScript($plainToken);
|
||||
return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,13 +43,17 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function readArtifact(string $fileName): string
|
||||
{
|
||||
if ($fileName === 'manifest.json') {
|
||||
return $this->buildManifest();
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
throw new Exception('Unable to read edge agent artifact');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
return $this->normalizeLineEndings($contents);
|
||||
}
|
||||
|
||||
public function contentType(string $fileName): string
|
||||
@@ -66,15 +71,55 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function artifactPath(string $fileName): string
|
||||
{
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
|
||||
throw new Exception('Unknown edge agent artifact');
|
||||
}
|
||||
|
||||
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildManifest(): string
|
||||
{
|
||||
$artifacts = [];
|
||||
foreach (self::ARTIFACTS as $fileName => $contentType) {
|
||||
if ($fileName === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$sha256 = hash_file('sha256', $path);
|
||||
$bytes = filesize($path);
|
||||
if ($sha256 === false || $bytes === false) {
|
||||
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
|
||||
}
|
||||
|
||||
$artifacts[] = [
|
||||
'name' => $fileName,
|
||||
'sha256' => $sha256,
|
||||
'bytes' => $bytes,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'schema_version' => 1,
|
||||
'package' => 'truckwash-edge-agent',
|
||||
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
|
||||
'generated_at' => gmdate('c'),
|
||||
'artifacts' => $artifacts,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
}
|
||||
|
||||
private function normalizeLineEndings(string $contents): string
|
||||
{
|
||||
return str_replace(["\r\n", "\r"], "\n", $contents);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$pdo = self::pdo();
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
|
||||
INDEX idx_edge_gateway_command_type (command_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
relay_id VARCHAR(255) NOT NULL,
|
||||
device_id VARCHAR(255) NULL,
|
||||
local_ip VARCHAR(64) NULL,
|
||||
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
|
||||
expected_state TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL,
|
||||
completed_at DATETIME NULL,
|
||||
last_attempted_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
|
||||
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_expected_relay_department (department_id),
|
||||
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
||||
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
|
||||
|
||||
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
|
||||
|
||||
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
||||
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (self::tableHasColumn($table, $column)) {
|
||||
return;
|
||||
}
|
||||
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
|
||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||
}
|
||||
|
||||
$db->query(
|
||||
self::pdo()->exec(
|
||||
"ALTER TABLE `$table`
|
||||
ADD COLUMN `$column` $definition"
|
||||
);
|
||||
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
|
||||
string $definition,
|
||||
?string $afterColumn = null
|
||||
): void {
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
||||
return;
|
||||
}
|
||||
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
||||
|
||||
$db->query(
|
||||
self::pdo()->exec(
|
||||
"ALTER TABLE `$table`
|
||||
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
||||
);
|
||||
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
|
||||
|
||||
private static function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = $db->escape_string($table);
|
||||
$column = $db->escape_string($column);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS c
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
|
||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$statement = self::pdo()->prepare(
|
||||
"SELECT COUNT(*) AS c
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table
|
||||
AND COLUMN_NAME = :column"
|
||||
);
|
||||
$statement->execute([
|
||||
':table' => $table,
|
||||
':column' => $column,
|
||||
]);
|
||||
|
||||
$row = $statement->fetch();
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
|
||||
private static function syncOperationTypeColumns(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
||||
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
$pdo = self::pdo();
|
||||
$pdo->exec(
|
||||
"UPDATE edge_gateway_operations
|
||||
SET type = operation_type
|
||||
WHERE operation_type IS NOT NULL
|
||||
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
|
||||
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
$pdo->exec(
|
||||
"UPDATE edge_gateway_operations
|
||||
SET operation_type = type
|
||||
WHERE type IS NOT NULL
|
||||
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
|
||||
AND (operation_type IS NULL OR operation_type = '')"
|
||||
);
|
||||
}
|
||||
|
||||
private static function pdo(): \PDO
|
||||
{
|
||||
$envConfig = self::databaseConfigFromEnvironment();
|
||||
if ($envConfig !== null) {
|
||||
return self::connectPdo($envConfig);
|
||||
}
|
||||
|
||||
if (!class_exists(db::class, false)) {
|
||||
$dbClassPath = __DIR__ . '/../../../classes/db.php';
|
||||
if (is_file($dbClassPath)) {
|
||||
require_once $dbClassPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(db::class)) {
|
||||
throw new \RuntimeException('Database connection helper is not available.');
|
||||
}
|
||||
|
||||
return db::getPDO();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,user:string,password:string,database:string,port:int}|null
|
||||
*/
|
||||
private static function databaseConfigFromEnvironment(): ?array
|
||||
{
|
||||
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
|
||||
if ($target !== 'debug') {
|
||||
$target = 'live';
|
||||
}
|
||||
|
||||
$host = self::databaseEnvValue('HOST', $target);
|
||||
$user = self::databaseEnvValue('USER', $target);
|
||||
$database = self::databaseEnvValue('DATABASE', $target);
|
||||
if ($host === '' || $user === '' || $database === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => self::databaseEnvValue('PASSWORD', $target),
|
||||
'database' => $database,
|
||||
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
|
||||
];
|
||||
}
|
||||
|
||||
private static function databaseEnvValue(string $key, string $target): string
|
||||
{
|
||||
$liveValue = self::envString('CONFIG_DB_' . $key);
|
||||
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
|
||||
|
||||
if ($target === 'debug' && $debugValue !== '') {
|
||||
return $debugValue;
|
||||
}
|
||||
|
||||
return $liveValue;
|
||||
}
|
||||
|
||||
private static function envString(string $key): string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value === false || $value === null) {
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
|
||||
}
|
||||
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{host:string,user:string,password:string,database:string,port:int} $config
|
||||
*/
|
||||
private static function connectPdo(array $config): \PDO
|
||||
{
|
||||
$port = $config['port'] > 0 ? $config['port'] : 3306;
|
||||
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
|
||||
|
||||
return new \PDO($dsn, $config['user'], $config['password'], [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||
\PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
|
||||
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
|
||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
||||
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
|
||||
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
|
||||
$response->error('Missing token', 400);
|
||||
}
|
||||
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
try {
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleAgentInstallTokenStatus(): void
|
||||
@@ -407,17 +415,21 @@ class edgeGatewaysRoute
|
||||
self::requireParameters(['token', 'status']);
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
try {
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function renderArtifact(string $fileName): void
|
||||
@@ -439,15 +451,19 @@ class edgeGatewaysRoute
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
$response->success(
|
||||
$this->registry()->claimGateway(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
),
|
||||
201
|
||||
);
|
||||
try {
|
||||
$response->success(
|
||||
$this->registry()->claimGateway(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
),
|
||||
201
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleAgentHeartbeat(): void
|
||||
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentExpectedRelayStates(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
|
||||
$gatewayId,
|
||||
$this->requireAgentToken($payload),
|
||||
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentRelayStateResults(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
|
||||
$response->success($this->manager()->recordExpectedRelayStateResults(
|
||||
$gatewayId,
|
||||
$this->requireAgentToken($payload),
|
||||
$results
|
||||
));
|
||||
}
|
||||
|
||||
private function handleAgentPresence(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerSelfserveMachineSignal(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
|
||||
$response->success($result, !empty($result['recorded']) ? 201 : 202);
|
||||
} catch (\Throwable $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerBrowserStreamValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace email\templates;
|
||||
|
||||
use email\helpers\email_template;use objects\users_o;
|
||||
use email\helpers\email_template;
|
||||
use objects\users_o;
|
||||
|
||||
class email_template_new_customer
|
||||
{
|
||||
@@ -52,6 +53,7 @@ class email_template_new_customer
|
||||
*/
|
||||
public function generate_html(): string
|
||||
{
|
||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
||||
ob_start();
|
||||
# Start of the html
|
||||
?>
|
||||
@@ -73,7 +75,7 @@ class email_template_new_customer
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
||||
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
|
||||
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
|
||||
</p>
|
||||
|
||||
<!-- You can now wash your trucks -->
|
||||
@@ -185,4 +187,19 @@ class email_template_new_customer
|
||||
# End of the html
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
private function getCustomerRegistrationLabel(): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
|
||||
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
|
||||
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
|
||||
|
||||
$customer->getCustomerEcocomicData($this->customer_number);
|
||||
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
|
||||
if ($corporate_identification_number !== '') {
|
||||
$customer_label .= ' (' . $corporate_identification_number . ')';
|
||||
}
|
||||
|
||||
return $customer_label;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class selfserve_lane_command_arguments
|
||||
public ?string $license_plate = null;
|
||||
public ?int $customer_number = null;
|
||||
public ?int $subuser_id = null;
|
||||
public ?string $wash_mode = null;
|
||||
public bool $defer_relay_side_effects = false;
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,22 @@ class selfserve_lane_command_arguments
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setWashMode(?string $wash_mode): self
|
||||
{
|
||||
$normalized = strtolower(trim((string)$wash_mode));
|
||||
if ($wash_mode === null || $normalized === '') {
|
||||
$this->wash_mode = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (!in_array($normalized, ['manual', 'machine'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
|
||||
}
|
||||
|
||||
$this->wash_mode = $normalized;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||
{
|
||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||
@@ -50,6 +67,12 @@ class selfserve_lane_command_arguments
|
||||
if (array_key_exists('subuser_id', $params)) {
|
||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||
}
|
||||
if (array_key_exists('wash_type', $params)) {
|
||||
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
|
||||
}
|
||||
if (array_key_exists('wash_mode', $params)) {
|
||||
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
|
||||
}
|
||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||
$this->setDeferRelaySideEffects(filter_var(
|
||||
$params['defer_relay_side_effects'],
|
||||
|
||||
@@ -148,6 +148,25 @@ class selfserve_machine_signal
|
||||
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
||||
$departmentId = (int)$gateway->department_id->value();
|
||||
|
||||
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
|
||||
{
|
||||
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
|
||||
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
|
||||
{
|
||||
return $this->recordCloudShellySignal(
|
||||
$departmentId,
|
||||
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
||||
|
||||
@@ -48,8 +48,8 @@ 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;
|
||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
|
||||
private const MAX_PATH_SAMPLE_LIMIT = 2048;
|
||||
|
||||
/** @var array<string,array<int,string>> */
|
||||
private array $columnCache = [];
|
||||
|
||||
@@ -103,49 +103,79 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$mutationResult = $this->withSessionMutationLock(
|
||||
$laneId,
|
||||
$snapshot['reg'],
|
||||
$snapshot['customer_number'],
|
||||
function () use ($laneId, $snapshot, $options): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$createSession = (bool)($options['create_session'] ?? true);
|
||||
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot);
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$session->exists() && !$createSession) {
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => $this->formatSnapshotResponse($snapshot, null),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$session->exists()) {
|
||||
$session = (new selfserve_wash_sessions_o())->add(
|
||||
$laneId,
|
||||
(int)$snapshot['lane']['department'],
|
||||
$snapshot['machine_type']['id'] ?? null,
|
||||
$snapshot['customer_number'],
|
||||
$snapshot['reg'],
|
||||
$snapshot['vehicle']['id'] ?? null,
|
||||
$snapshot['vehicle']['type'] ?? null,
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
$session->allowed->set((bool)$snapshot['allowed']);
|
||||
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||
}
|
||||
|
||||
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
||||
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
||||
'allowed' => (bool)$snapshot['allowed'],
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
]);
|
||||
|
||||
return [
|
||||
'session' => $session,
|
||||
'response' => null,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
$session = $mutationResult['session'];
|
||||
if ($mutationResult['response'] !== null) {
|
||||
return $mutationResult['response'];
|
||||
}
|
||||
|
||||
if (!$session->exists()) {
|
||||
$session = (new selfserve_wash_sessions_o())->add(
|
||||
$laneId,
|
||||
(int)$snapshot['lane']['department'],
|
||||
$snapshot['machine_type']['id'] ?? null,
|
||||
$snapshot['customer_number'],
|
||||
$snapshot['reg'],
|
||||
$snapshot['vehicle']['id'] ?? null,
|
||||
$snapshot['vehicle']['type'] ?? null,
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
$session->allowed->set((bool)$snapshot['allowed']);
|
||||
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||
}
|
||||
|
||||
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
||||
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
||||
'allowed' => (bool)$snapshot['allowed'],
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
]);
|
||||
|
||||
if ($syncRelayState) {
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
if ($session->exists()) {
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
@@ -330,9 +360,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$allowedServices = $this->normalizeServiceNames(
|
||||
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
||||
);
|
||||
$machineAvailable = array_key_exists('machine_available', $metadata)
|
||||
$machineWashEnabled = $this->isMachineWashEnabled();
|
||||
if (!$machineWashEnabled) {
|
||||
$allowedServices = $this->withoutMachineService($allowedServices);
|
||||
}
|
||||
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
|
||||
? (bool)$metadata['machine_available']
|
||||
: ($lane->exists() && !empty($lane->relay_machine_id->value()));
|
||||
: ($lane->exists() && !empty($lane->relay_machine_id->value())));
|
||||
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
|
||||
? (bool)$metadata['all_visible_questions_answered']
|
||||
: true;
|
||||
@@ -378,6 +412,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'events' => $events,
|
||||
'allowed_services' => $allowedServices,
|
||||
'machine_available' => $machineAvailable,
|
||||
'machine_wash_enabled' => $machineWashEnabled,
|
||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||
'allowed' => (bool)$session->allowed->value(),
|
||||
'config_version_id' => $metadata['config_version_id'] ?? null,
|
||||
@@ -395,7 +430,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||
{
|
||||
$session = $reg !== null
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
@@ -405,8 +440,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->markCompleted($orderId);
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
if (!$session->markCompletedIfOpen($orderId)) {
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
if ($disableRelays) {
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
}
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
@@ -450,9 +489,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'runtime_before_reset' => $runtimeSnapshot,
|
||||
'forced_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$session->markForceStopped($orderId, $eventPayload);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
} else {
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
}
|
||||
|
||||
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||
@@ -650,7 +692,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
|
||||
$machineAvailable = !empty($lane->relay_machine_id->value());
|
||||
$machineWashEnabled = $this->isMachineWashEnabled();
|
||||
if (!$machineWashEnabled) {
|
||||
$allowedServices = $this->withoutMachineService($allowedServices);
|
||||
}
|
||||
|
||||
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
|
||||
$allVisibleQuestionsAnswered = true;
|
||||
foreach ($visibleQuestions as $question) {
|
||||
if ($question['answer'] === null) {
|
||||
@@ -689,8 +736,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'tasks' => $visibleTasks,
|
||||
'allowed_services' => $allowedServices,
|
||||
'machine_available' => $machineAvailable,
|
||||
'machine_wash_enabled' => $machineWashEnabled,
|
||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||
'allowed' => $machineAllowed,
|
||||
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
|
||||
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
|
||||
'config_source' => $configSource,
|
||||
'evaluation_trace' => [
|
||||
@@ -789,6 +838,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'tasks' => $snapshot['tasks'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'machine_available' => $snapshot['machine_available'],
|
||||
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
|
||||
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
||||
'allowed' => $snapshot['allowed'],
|
||||
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
||||
@@ -2962,7 +3012,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||
return (int)($question['department'] ?? 0) === $departmentId
|
||||
&& (int)($question['lane'] ?? 0) === $laneId
|
||||
&& ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
|
||||
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
|
||||
}));
|
||||
}
|
||||
@@ -3005,7 +3055,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||
return (int)($condition['machine_type_id'] ?? 0) === 0
|
||||
&& (int)($condition['department'] ?? 0) === $departmentId
|
||||
&& (int)($condition['lane'] ?? 0) === $laneId
|
||||
&& ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
|
||||
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
|
||||
}));
|
||||
}
|
||||
@@ -3049,7 +3099,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||
return (int)($task['machine_type_id'] ?? 0) === 0
|
||||
&& (int)($task['department'] ?? 0) === $departmentId
|
||||
&& (int)($task['lane'] ?? 0) === $laneId
|
||||
&& ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
|
||||
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
|
||||
}));
|
||||
}
|
||||
@@ -3188,11 +3238,92 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():array<string,mixed> $callback
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
||||
{
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->releaseSessionMutationLock($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{driver:string,key:string,token:?string}
|
||||
*/
|
||||
protected function acquireSessionMutationLock(string $lockKey): array
|
||||
{
|
||||
if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
|
||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'driver' => 'redis',
|
||||
'key' => $lockKey,
|
||||
'token' => $token,
|
||||
];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
|
||||
$row = $db->fetch_assoc($result);
|
||||
if ((int)($row['acquired'] ?? 0) !== 1) {
|
||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
||||
}
|
||||
|
||||
return [
|
||||
'driver' => 'mysql',
|
||||
'key' => $lockKey,
|
||||
'token' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{driver:string,key:string,token:?string} $lock
|
||||
*/
|
||||
protected function releaseSessionMutationLock(array $lock): void
|
||||
{
|
||||
try {
|
||||
if ($lock['driver'] === 'redis' && defined('redis')) {
|
||||
if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
|
||||
return;
|
||||
}
|
||||
if (method_exists(redis, 'delete')) {
|
||||
redis->delete($lock['key']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lock['driver'] === 'mysql') {
|
||||
global $db;
|
||||
$db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Locks have TTLs or connection scope; release failures must not mask API results.
|
||||
}
|
||||
}
|
||||
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
||||
{
|
||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
);
|
||||
}
|
||||
|
||||
protected function buildSessionMetadata(array $snapshot): array
|
||||
{
|
||||
return [
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'machine_available' => (bool)$snapshot['machine_available'],
|
||||
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||
@@ -3491,6 +3622,27 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
));
|
||||
}
|
||||
|
||||
protected function isMachineWashEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return (new selfserve())->config->machine_wash_enabled->isTrue();
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $services
|
||||
* @return array<int,string>
|
||||
*/
|
||||
protected function withoutMachineService(array $services): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->normalizeServiceNames($services),
|
||||
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
|
||||
));
|
||||
}
|
||||
|
||||
protected function taskUsesMachineControls(array $task): bool
|
||||
{
|
||||
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class selfserve_dynamic_image_size_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public const SIZE_ORIGINAL = 'original';
|
||||
public const SIZE_RELEVANT = 'relevant';
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'selfserve',
|
||||
'dynamic_image_size',
|
||||
'string',
|
||||
true,
|
||||
[self::SIZE_ORIGINAL, self::SIZE_RELEVANT],
|
||||
'Whether self-serve dynamic images are served in the original rendered size or resized to the relevant terminal size',
|
||||
self::SIZE_RELEVANT,
|
||||
false,
|
||||
self::SIZE_ORIGINAL
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class selfserve_machine_wash_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'selfserve',
|
||||
'machine_wash_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether machine wash is available in customer-facing self-serve flows',
|
||||
'1',
|
||||
false,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
||||
|
||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
||||
|
||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ Public methods:
|
||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
||||
|
||||
Key implementation details:
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace modules\selfserve;
|
||||
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
||||
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_enabled_c.php';
|
||||
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
|
||||
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
|
||||
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
|
||||
|
||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
||||
use modules\selfserve\config\selfserve_enabled_c;
|
||||
use modules\selfserve\config\selfserve_machine_wash_enabled_c;
|
||||
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
|
||||
use modules\selfserve\config\selfserve_minute_product_c;
|
||||
use traits\module_config_t;
|
||||
@@ -19,6 +23,11 @@ class selfserve_c
|
||||
* @var selfserve_enabled_c $enabled
|
||||
*/
|
||||
public selfserve_enabled_c $enabled;
|
||||
/**
|
||||
* Whether machine wash is available in customer-facing self-serve flows
|
||||
* @var selfserve_machine_wash_enabled_c $machine_wash_enabled
|
||||
*/
|
||||
public selfserve_machine_wash_enabled_c $machine_wash_enabled;
|
||||
/**
|
||||
* The product ID used for minute-based self-serve billing
|
||||
* @var selfserve_minute_product_c $minute_product
|
||||
@@ -29,17 +38,26 @@ class selfserve_c
|
||||
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
|
||||
*/
|
||||
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
|
||||
/**
|
||||
* Dynamic image output size mode for self-serve terminals
|
||||
* @var selfserve_dynamic_image_size_c $dynamic_image_size
|
||||
*/
|
||||
public selfserve_dynamic_image_size_c $dynamic_image_size;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('selfserve');
|
||||
$this->allowUpdate([
|
||||
selfserve_enabled_c::class,
|
||||
selfserve_machine_wash_enabled_c::class,
|
||||
selfserve_minute_product_c::class,
|
||||
selfserve_machine_wash_minutes_included_c::class
|
||||
selfserve_machine_wash_minutes_included_c::class,
|
||||
selfserve_dynamic_image_size_c::class
|
||||
]);
|
||||
$this->enabled = new selfserve_enabled_c();
|
||||
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
|
||||
$this->minute_product = new selfserve_minute_product_c();
|
||||
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
||||
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,13 +171,12 @@ trait selfserve_lane_command_t
|
||||
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
||||
* If machine relay is not configured, this is a no-op.
|
||||
*/
|
||||
protected function setMachineRelayStatusForWashStart(): void
|
||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||
return;
|
||||
}
|
||||
$active_wash = new selfserve_wash_flow();
|
||||
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
|
||||
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
||||
try {
|
||||
$this->setMachineRelayStatusHard(true);
|
||||
} catch (\Throwable) {
|
||||
@@ -193,6 +192,116 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the program picker relay aligned with the selected wash mode at START.
|
||||
* It is ON only when the customer explicitly selected machine wash.
|
||||
*/
|
||||
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
||||
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
|
||||
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
||||
{
|
||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; wash start must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$reg = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
|
||||
if ($reg === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
||||
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
|
||||
(int)$this->id,
|
||||
$reg,
|
||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
||||
);
|
||||
|
||||
return (bool)($snapshot['allowed'] ?? false);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isMachineServiceSelectedForWashStart();
|
||||
}
|
||||
|
||||
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
||||
&& $this->isMachineServiceSelectedForWashStart();
|
||||
}
|
||||
|
||||
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
|
||||
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isMachineServiceSelectedForWashStart();
|
||||
}
|
||||
|
||||
protected function isMachineServiceSelectedForWashStart(): bool
|
||||
{
|
||||
try {
|
||||
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
||||
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
||||
if (is_array($services)) {
|
||||
foreach ($services as $service) {
|
||||
if (strtoupper((string)$service) === 'MACHINE') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall through to fail-closed when the selected service cache is unavailable.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function openEntrancePortForWashStart(): void
|
||||
{
|
||||
try {
|
||||
@@ -274,30 +383,26 @@ trait selfserve_lane_command_t
|
||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||
{
|
||||
if ($arguments->defer_relay_side_effects) {
|
||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cleaner relay is enabled whenever wash starts.
|
||||
$this->turnOnCleanerRelayForWashStart();
|
||||
// Ensure the machine relay is ON when a wash starts, when it is allowed.
|
||||
$this->setMachineRelayStatusForWashStart();
|
||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
||||
$this->setMachineRelayStatusForWashStart($arguments);
|
||||
}
|
||||
|
||||
protected function resolveSelfServeActionWashModeForStart(): string
|
||||
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
|
||||
{
|
||||
try {
|
||||
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
||||
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
||||
if (is_array($services)) {
|
||||
foreach ($services as $service) {
|
||||
if (strtoupper((string)$service) === 'MACHINE') {
|
||||
return selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall through to manual mode when the cached service set is unavailable.
|
||||
if ($arguments !== null && in_array($arguments->wash_mode, [
|
||||
selfserve_studio_actions::MODE_MANUAL,
|
||||
selfserve_studio_actions::MODE_MACHINE,
|
||||
], true)) {
|
||||
return $arguments->wash_mode;
|
||||
}
|
||||
|
||||
if ($this->isMachineServiceSelectedForWashStart()) {
|
||||
return selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
|
||||
return selfserve_studio_actions::MODE_MANUAL;
|
||||
@@ -382,14 +487,16 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable relays after STOP in deterministic order:
|
||||
* Disable relays before opening exit gates in deterministic order:
|
||||
* 1. Cleaner relay
|
||||
* 2. Machine relay
|
||||
* 2. Program picker relay
|
||||
* 3. Machine relay
|
||||
*/
|
||||
protected function turnOffRelaysAfterStop(): void
|
||||
{
|
||||
$relays = [
|
||||
selfserve_lane_relay::MACHINE_CLEANER,
|
||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
||||
selfserve_lane_relay::MACHINE,
|
||||
];
|
||||
|
||||
@@ -431,7 +538,8 @@ trait selfserve_lane_command_t
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Session completion must not block STOP flow.
|
||||
@@ -563,8 +671,17 @@ trait selfserve_lane_command_t
|
||||
// Set the customer number and license plate
|
||||
$this->setCustomerNumber($customer_number);
|
||||
$this->setLicensePlate($license_plate);
|
||||
// Mark the lane occupied before any physical entrance relay side effects.
|
||||
// Mark the lane occupied before any physical relay or gate side effects.
|
||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||
$this->runRelaySideEffectsForWashStart($arguments);
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||
$this->resolveSelfServeActionWashModeForStart($arguments),
|
||||
[
|
||||
'customer_number' => (int)$customer_number,
|
||||
'reg' => $license_plate,
|
||||
]
|
||||
);
|
||||
try {
|
||||
// Gateway timeouts are ambiguous because the relay may already have received
|
||||
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
||||
@@ -580,15 +697,6 @@ trait selfserve_lane_command_t
|
||||
$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 {
|
||||
@@ -604,6 +712,8 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
// Snapshot the physical machine ON signal before session completion/reset.
|
||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||
// Turn off relays before any configured or default exit gate opens.
|
||||
$this->turnOffRelaysAfterStop();
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
||||
@@ -616,8 +726,6 @@ trait selfserve_lane_command_t
|
||||
// Open the exit port. Gateway timeouts are ambiguous because
|
||||
// the relay may already have received the pulse.
|
||||
$this->openExitPortForWashStop();
|
||||
// Turn off relays in deterministic order after STOP
|
||||
$this->turnOffRelaysAfterStop();
|
||||
// Log the lane stop event
|
||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||
// Invoice the customer
|
||||
|
||||
@@ -12,6 +12,7 @@ use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\shelly\helpers\shelly_request_body_get_states;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
|
||||
trait selfserve_lane_relay_controller_t
|
||||
{
|
||||
@@ -917,7 +918,38 @@ trait selfserve_lane_relay_controller_t
|
||||
}
|
||||
}
|
||||
|
||||
return $this->sendRelaySwitchCommand($relay, true, $duration);
|
||||
$result = $this->sendRelaySwitchCommand($relay, true, $duration);
|
||||
if ($result && $relay === selfserve_lane_relay::MACHINE) {
|
||||
$this->markLatestSelfServeSessionRelayEnabledForLane();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function markLatestSelfServeSessionRelayEnabledForLane(): void
|
||||
{
|
||||
try {
|
||||
$customerNumber = (int)$this->getCustomerNumber();
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
|
||||
(int)$this->id,
|
||||
$customerNumber > 0 ? $customerNumber : null
|
||||
);
|
||||
if (!$session->exists() && $customerNumber > 0) {
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane((int)$this->id);
|
||||
}
|
||||
if (!$session->exists() || (bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->markRelayEnabled();
|
||||
} catch (\Throwable $e) {
|
||||
error_log(
|
||||
'Failed to synchronize self-serve machine relay session state for lane '
|
||||
. (int)$this->id
|
||||
. ': '
|
||||
. $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace slack\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class slack_customer_registration_webhook_url_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Slack',
|
||||
'customer_registration_webhook_url',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Slack webhook URL used for successful customer registration notifications',
|
||||
'https://hooks.slack.com/services/...',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace slack\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class slack_internal_department_goal_progress_webhook_url_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Slack',
|
||||
'internal_department_goal_progress_webhook_url',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Slack webhook URL used for internal department goal progress notifications',
|
||||
'https://hooks.slack.com/services/...',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace slack\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class slack_internal_department_ids_c
|
||||
{
|
||||
use module_config_variable {
|
||||
setVariableValue as private traitSetVariableValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Slack',
|
||||
'internal_department_ids',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'JSON encoded department IDs considered internal departments for Slack goal progress notifications',
|
||||
'[1,2,3]',
|
||||
false,
|
||||
'[]'
|
||||
);
|
||||
}
|
||||
|
||||
public function getDepartmentIds(): array
|
||||
{
|
||||
return self::normalizeDepartmentIds($this->getVariableValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setVariableValue(mixed $value): void
|
||||
{
|
||||
$this->traitSetVariableValue(json_encode(
|
||||
self::normalizeDepartmentIds($value),
|
||||
JSON_THROW_ON_ERROR
|
||||
));
|
||||
}
|
||||
|
||||
public static function normalizeDepartmentIds(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (is_array($decoded)) {
|
||||
$value = $decoded;
|
||||
} else {
|
||||
$value = array_filter(array_map('trim', explode(',', $value)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$departmentIds = [];
|
||||
foreach ($value as $departmentId) {
|
||||
$departmentId = (int)$departmentId;
|
||||
if ($departmentId > 0) {
|
||||
$departmentIds[] = $departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
$departmentIds = array_values(array_unique($departmentIds));
|
||||
sort($departmentIds);
|
||||
|
||||
return $departmentIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace slack;
|
||||
|
||||
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
|
||||
require_once WD . '/modules/slack/config/slack_internal_department_goal_progress_webhook_url_c.php';
|
||||
require_once WD . '/modules/slack/config/slack_internal_department_ids_c.php';
|
||||
|
||||
use slack\config\slack_customer_registration_webhook_url_c;
|
||||
use slack\config\slack_internal_department_goal_progress_webhook_url_c;
|
||||
use slack\config\slack_internal_department_ids_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class slack_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
|
||||
public slack_internal_department_goal_progress_webhook_url_c $internal_department_goal_progress_webhook_url;
|
||||
public slack_internal_department_ids_c $internal_department_ids;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('Slack');
|
||||
$this->allowUpdate([
|
||||
slack_customer_registration_webhook_url_c::class,
|
||||
slack_internal_department_goal_progress_webhook_url_c::class,
|
||||
slack_internal_department_ids_c::class,
|
||||
]);
|
||||
|
||||
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
|
||||
$this->internal_department_goal_progress_webhook_url = new slack_internal_department_goal_progress_webhook_url_c();
|
||||
$this->internal_department_ids = new slack_internal_department_ids_c();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $closed_at;
|
||||
public int $economic_wash_subscription_user_id = 1857;
|
||||
private ?array $last_economic_transfer_metrics = null;
|
||||
/**
|
||||
* The processor types
|
||||
*
|
||||
@@ -606,6 +607,76 @@ class collected_order_invoices_o extends db
|
||||
self::deleteCached('asArray', $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move this invoice collection and all attached orders to another customer.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function moveToCustomer(int $target_customer_number): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
self::requireSelected();
|
||||
self::requireValidCustomer((string)$target_customer_number);
|
||||
|
||||
if ($target_customer_number <= 0) {
|
||||
throw new Exception('Target customer number must be greater than zero');
|
||||
}
|
||||
|
||||
if (!empty($this->external_id->value()) || $this->booked_invoice_id->value() !== null) {
|
||||
throw new Exception('Invoice collections with an external or booked invoice cannot be moved');
|
||||
}
|
||||
|
||||
$source_customer_number = (int)$this->customer_number->value();
|
||||
if ($source_customer_number === $target_customer_number) {
|
||||
return [
|
||||
'invoice_collection_id' => (int)$this->id,
|
||||
'source_customer_number' => $source_customer_number,
|
||||
'target_customer_number' => $target_customer_number,
|
||||
'moved_order_ids' => [],
|
||||
'moved_order_count' => 0,
|
||||
'changed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$invoice_collection_id = (int)$this->id;
|
||||
$result = $db->query("SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}");
|
||||
$order_ids = array_map(
|
||||
static fn(array $row): int => (int)$row['id'],
|
||||
$db->fetch_all($result)
|
||||
);
|
||||
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$this->customer_number->set($target_customer_number);
|
||||
|
||||
foreach ( $order_ids as $order_id ) {
|
||||
$order = (new orders_o())->select($order_id);
|
||||
if (!$order->exists()) {
|
||||
continue;
|
||||
}
|
||||
$order->customer_id->set($target_customer_number);
|
||||
$order->objectChanged();
|
||||
}
|
||||
|
||||
$this->objectChanged();
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->conn()->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'invoice_collection_id' => $invoice_collection_id,
|
||||
'source_customer_number' => $source_customer_number,
|
||||
'target_customer_number' => $target_customer_number,
|
||||
'moved_order_ids' => $order_ids,
|
||||
'moved_order_count' => count($order_ids),
|
||||
'changed' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the external id of the invoice collection
|
||||
* @throws Exception If the request was not successful
|
||||
@@ -642,6 +713,7 @@ class collected_order_invoices_o extends db
|
||||
*/
|
||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||
{
|
||||
$this->last_economic_transfer_metrics = null;
|
||||
// Require the invoice collection to be selected
|
||||
self::requireSelected();
|
||||
// Require the invoice collection to be open
|
||||
@@ -666,10 +738,20 @@ class collected_order_invoices_o extends db
|
||||
usort($orders, function ($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
// Add the invoices to the invoice draft
|
||||
// Add the invoice lines to the draft in one accumulated batch path.
|
||||
$order_objects = [];
|
||||
foreach ( $orders as $order ) {
|
||||
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
|
||||
$order_object = new orders_o();
|
||||
$order_object->select((int)$order['id']);
|
||||
$order_object->requireSelected();
|
||||
$order_objects[] = $order_object;
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
||||
$this->last_economic_transfer_metrics = [
|
||||
'draft_invoice_id' => $draft_id,
|
||||
'currency' => (string)$currency,
|
||||
...$metrics,
|
||||
];
|
||||
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||
// Object changed
|
||||
@@ -678,6 +760,11 @@ class collected_order_invoices_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastEconomicTransferMetrics(): ?array
|
||||
{
|
||||
return $this->last_economic_transfer_metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
||||
* @param int $draft_id The invoice draft id
|
||||
|
||||
@@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const TOKEN_LENGTH = 32;
|
||||
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$this->setTable('customer_password_reset_keys');
|
||||
}
|
||||
|
||||
private function validTokenWhereClause(): string
|
||||
{
|
||||
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new customer reset key
|
||||
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
|
||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
// Query the database for a valid token
|
||||
$current_time = date('Y-m-d H:i:s');
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
|
||||
// Query the database for a valid token using the same clock that writes created_at.
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return null;
|
||||
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
|
||||
*/
|
||||
public function isValidToken(): bool
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$created_at = strtotime($this->created_at->value());
|
||||
$current_time = time();
|
||||
return (
|
||||
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||
($this->deleted_at->value() === null
|
||||
);
|
||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
return $result->num_rows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use traits\db_object_t;
|
||||
|
||||
class department_lanes_o extends db
|
||||
@@ -324,9 +325,52 @@ class department_lanes_o extends db
|
||||
public function getSelfServeLaneProducts(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
$publishedProducts = $this->getPublishedSelfServeLaneProducts();
|
||||
if ($publishedProducts !== []) {
|
||||
return $publishedProducts;
|
||||
}
|
||||
|
||||
return department_selfserve_tasks_o::getLaneProducts((int)$this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function getPublishedSelfServeLaneProducts(): array
|
||||
{
|
||||
try {
|
||||
$published = (new selfserve_config_versioning())->getPublishedV2Config((int)$this->department->value());
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = is_array($published['config'] ?? null) ? $published['config'] : null;
|
||||
if ($config === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$laneId = (int)$this->id;
|
||||
$products = [];
|
||||
foreach ((array)($config['tasks'] ?? []) as $task) {
|
||||
if (!is_array($task)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$taskLane = (int)($task['lane'] ?? 0);
|
||||
if ($taskLane !== 0 && $taskLane !== $laneId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productId = (int)($task['product'] ?? 0);
|
||||
if ($productId > 0 && !in_array($productId, $products, true)) {
|
||||
$products[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
sort($products, SORT_NUMERIC);
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
* @return department_lanes_o[] An array of department lane objects for the specified department
|
||||
|
||||
@@ -718,7 +718,7 @@ class departments_o extends db
|
||||
* @return null|array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids, array $product_ids = [
|
||||
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids = [], array $product_ids = [
|
||||
25,
|
||||
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
||||
22,
|
||||
@@ -727,6 +727,11 @@ class departments_o extends db
|
||||
26
|
||||
], bool $return_as_array = false): null|array
|
||||
{
|
||||
$slack = new slack();
|
||||
if (empty($department_ids)) {
|
||||
$department_ids = $slack->get_internal_department_ids();
|
||||
}
|
||||
|
||||
if (empty($department_ids)) {
|
||||
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
||||
}
|
||||
@@ -847,9 +852,11 @@ class departments_o extends db
|
||||
$array_of_results['daily_management'][] = $tmp;
|
||||
|
||||
// Send the message to the internal Slack webhook
|
||||
$slack = new slack();
|
||||
if (!$return_as_array) {
|
||||
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
|
||||
$webhook = $slack->get_internal_department_goal_progress_webhook_url();
|
||||
if ($webhook !== '') {
|
||||
$slack->send_webhook_message($tmp, $webhook);
|
||||
}
|
||||
}
|
||||
// Send a department-specific message for each internal department with the percentage of addons sold
|
||||
foreach ( $department_ids as $department_id ) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_expected_relay_states_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $lane_id;
|
||||
public object_property $role;
|
||||
public object_property $relay_id;
|
||||
public object_property $device_id;
|
||||
public object_property $local_ip;
|
||||
public object_property $device_type;
|
||||
public object_property $expected_state;
|
||||
public object_property $updated_at;
|
||||
public object_property $completed_at;
|
||||
public object_property $last_attempted_at;
|
||||
public object_property $last_error;
|
||||
public object_property $metadata_json;
|
||||
public object_property $created_at;
|
||||
public object_property $row_updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_expected_relay_states');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false);
|
||||
$this->role = new object_property($this->table, $this->id, 'role', 'string', false);
|
||||
$this->relay_id = new object_property($this->table, $this->id, 'relay_id', 'string', false);
|
||||
$this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false);
|
||||
$this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false);
|
||||
$this->device_type = new object_property($this->table, $this->id, 'device_type', 'string', false);
|
||||
$this->expected_state = new object_property($this->table, $this->id, 'expected_state', 'bool', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||
$this->last_attempted_at = new object_property($this->table, $this->id, 'last_attempted_at', 'string', false);
|
||||
$this->last_error = new object_property($this->table, $this->id, 'last_error', 'text', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->row_updated_at = new object_property($this->table, $this->id, 'row_updated_at', 'timestamp', false);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'lane_id' => (int)$this->lane_id->value(),
|
||||
'role' => (string)$this->role->value(),
|
||||
'relay_id' => (string)$this->relay_id->value(),
|
||||
'device_id' => $this->device_id->value() === null ? null : (string)$this->device_id->value(),
|
||||
'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(),
|
||||
'device_type' => (string)$this->device_type->value(),
|
||||
'state' => (bool)$this->expected_state->value(),
|
||||
'updated' => (string)$this->updated_at->value(),
|
||||
'completed' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'last_attempted_at' => $this->last_attempted_at->value() === null ? null : (string)$this->last_attempted_at->value(),
|
||||
'last_error' => $this->last_error->value() === null ? null : (string)$this->last_error->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'row_updated_at' => $this->row_updated_at->value() === null ? null : (string)$this->row_updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,18 @@ class selfserve_wash_sessions_o extends db
|
||||
}
|
||||
|
||||
public function markCompleted(?int $orderId = null): void
|
||||
{
|
||||
if (!$this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public function markCompletedIfOpen(?int $orderId = null): bool
|
||||
{
|
||||
return $this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId);
|
||||
}
|
||||
|
||||
private function markCompletedInMemory(?int $orderId = null): void
|
||||
{
|
||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||
if ($orderId !== null) {
|
||||
@@ -167,6 +179,18 @@ class selfserve_wash_sessions_o extends db
|
||||
}
|
||||
|
||||
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
|
||||
{
|
||||
if (!$this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public function markForceStoppedIfOpen(?int $orderId = null, ?array $metadata = null): bool
|
||||
{
|
||||
return $this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata);
|
||||
}
|
||||
|
||||
private function markForceStoppedInMemory(?int $orderId = null, ?array $metadata = null): void
|
||||
{
|
||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||
if ($orderId !== null) {
|
||||
@@ -181,6 +205,53 @@ class selfserve_wash_sessions_o extends db
|
||||
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
|
||||
}
|
||||
|
||||
private function closeIfOpen(selfserve_wash_session_status $status, ?int $orderId = null, ?array $metadata = null): bool
|
||||
{
|
||||
if (!$this->isPersistedSession()) {
|
||||
if ($status === selfserve_wash_session_status::COMPLETED) {
|
||||
$this->markCompletedInMemory($orderId);
|
||||
} else {
|
||||
$this->markForceStoppedInMemory($orderId, $metadata);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$updates = [
|
||||
"`completed_at` = NOW()",
|
||||
"`status` = '" . $db->escape_string($status->value) . "'",
|
||||
];
|
||||
|
||||
if ($orderId !== null) {
|
||||
$updates[] = "`order_id` = " . (int)$orderId;
|
||||
}
|
||||
|
||||
if ($metadata !== null) {
|
||||
$existing = $this->metadata_json->value();
|
||||
$existing = is_array($existing) ? $existing : [];
|
||||
$existing['force_stop'] = $metadata;
|
||||
$updates[] = "`metadata_json` = '" . $db->escape_string(json_encode($existing, JSON_THROW_ON_ERROR)) . "'";
|
||||
}
|
||||
|
||||
$terminalStatuses = self::terminalStatusSqlList();
|
||||
$db->query(
|
||||
"UPDATE `selfserve_wash_sessions` SET " . implode(', ', $updates) .
|
||||
" WHERE `id` = " . (int)$this->id .
|
||||
" AND `completed_at` IS NULL" .
|
||||
" AND UPPER(TRIM(`status`)) NOT IN ($terminalStatuses)"
|
||||
);
|
||||
|
||||
$changed = $db->conn()->affected_rows > 0;
|
||||
$this->select((int)$this->id);
|
||||
return $changed;
|
||||
}
|
||||
|
||||
private function isPersistedSession(): bool
|
||||
{
|
||||
return isset($this->id) && (int)$this->id > 0 && $this->exists();
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||
{
|
||||
$filters = [
|
||||
|
||||
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
|
||||
public object_property $deleted_at;
|
||||
const defaultPermissions = [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'ORDERS_LIST',
|
||||
];
|
||||
|
||||
public static function normalizePermissionsValue(mixed $raw): array
|
||||
|
||||
@@ -171,6 +171,7 @@ class subusers_o extends db
|
||||
return $this;
|
||||
} catch (Exception $e) {
|
||||
$response->error($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +218,16 @@ class subusers_o extends db
|
||||
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
||||
}
|
||||
$cache_key = 'setup_token:' . $token;
|
||||
$cashe_object_id = 'subuser_setup_token';
|
||||
$this->cache($cache_key, $this->id, $cashe_object_id);
|
||||
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
||||
$cache_object_id = 'subuser_setup_token';
|
||||
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
|
||||
if (is_string($existingToken) && $existingToken !== '') {
|
||||
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
|
||||
}
|
||||
$this->cache($cache_key, $this->id, $cache_object_id);
|
||||
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
|
||||
$this->cache($reverse_cache_key, $token, $cache_object_id);
|
||||
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
|
||||
return $token;
|
||||
}
|
||||
|
||||
@@ -260,8 +268,16 @@ class subusers_o extends db
|
||||
public function invalidateSetupToken(string $token): void
|
||||
{
|
||||
$object_id = 'subuser_setup_token';
|
||||
$subuser_id = $this->getSubuserIdBySetupToken($token);
|
||||
$cache_key = 'setup_token:' . $token;
|
||||
$this->deleteCached($cache_key, $object_id);
|
||||
if ($subuser_id !== null) {
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
|
||||
$currentToken = $this->getCached($reverse_cache_key, $object_id);
|
||||
if ((string)$currentToken === $token) {
|
||||
$this->deleteCached($reverse_cache_key, $object_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,8 @@ class users_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS = 'superuser_new_customer_email_notifications_enabled';
|
||||
|
||||
public object_property $customer_number;
|
||||
public object_property $display_name;
|
||||
public object_property $group_id;
|
||||
@@ -125,40 +127,47 @@ class users_o extends db
|
||||
|
||||
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
||||
{
|
||||
global $db;
|
||||
// Get the customer data from the external source
|
||||
$economic = new economicCustomers();
|
||||
$customer_data = $economic->getCustomerId($customer_number);
|
||||
// DEBUG: Return the customer data
|
||||
// Check if the customer exists
|
||||
if ($customer_data) {
|
||||
// Avoid SQL injection
|
||||
$customer_number = $db->escape_string($customer_data->customerNumber);
|
||||
// Double check if the customer exists
|
||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = $result->fetch_assoc()['id'];
|
||||
$this->getObjectProperties();
|
||||
} else {
|
||||
// Import the customer
|
||||
$this->add($customer_number, '', 0);
|
||||
// Nullify the password
|
||||
$this->password->nullify();
|
||||
// If the customer has an email address, save it
|
||||
if (isset($customer_data->email)) {
|
||||
$this->email->set($customer_data->email);
|
||||
}
|
||||
// If the customer has a name, save it as the display name
|
||||
if (isset($customer_data->name)) {
|
||||
$this->display_name->set($customer_data->name);
|
||||
}
|
||||
}
|
||||
return $this->importCustomerFromEconomicCustomerData($customer_data);
|
||||
}
|
||||
// Else return false
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function importCustomerFromEconomicCustomerData(object $customer_data): users_o|bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($customer_data->customerNumber) || !is_numeric($customer_data->customerNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customer_number = $db->escape_string((string)$customer_data->customerNumber);
|
||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = (int)$result->fetch_assoc()['id'];
|
||||
$this->getObjectProperties();
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->add($customer_number, '', 0);
|
||||
$this->password->nullify();
|
||||
|
||||
if (isset($customer_data->email)) {
|
||||
$this->email->set($customer_data->email);
|
||||
}
|
||||
|
||||
if (isset($customer_data->name)) {
|
||||
$this->display_name->set($customer_data->name);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -256,7 +265,7 @@ class users_o extends db
|
||||
* @param int|null $user_id The user id to add the attribute to
|
||||
* @throws Exception If the user is not selected, and the user_id is null
|
||||
*/
|
||||
public function addAttribute(string $attribute, int $user_id = null): void
|
||||
public function addAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -270,7 +279,7 @@ class users_o extends db
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function deleteAttribute(string $attribute, int $user_id = null): void
|
||||
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -302,7 +311,7 @@ class users_o extends db
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function doesUserHaveAttribute(string $attribute, int $user_id = null): bool
|
||||
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -436,6 +445,7 @@ class users_o extends db
|
||||
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
|
||||
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
|
||||
'wash_certificate_email' => $this->wash_certificate_email->value(),
|
||||
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => $this->isSuperuserNewCustomerEmailNotificationsEnabled(),
|
||||
],
|
||||
'created_at' => $this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value(),
|
||||
@@ -515,8 +525,12 @@ class users_o extends db
|
||||
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
||||
}
|
||||
|
||||
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
||||
public function getCustomerEcocomicData(?int $customer_number = null): users_o
|
||||
{
|
||||
if ($customer_number !== null && !isset($this->id)) {
|
||||
$this->getUserByCustomerNumber($customer_number);
|
||||
}
|
||||
|
||||
// Check if the customer number is set
|
||||
if (!isset($this->customer_number) && $customer_number === null) {
|
||||
return $this;
|
||||
@@ -528,7 +542,9 @@ class users_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
$cachedCustomer = $this->getCached('economic_customer');
|
||||
$cachedCustomer = isset($this->id) && $this->id > 0
|
||||
? $this->getCached('economic_customer')
|
||||
: null;
|
||||
if (is_object($cachedCustomer)) {
|
||||
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
||||
if ($cachedCustomerNumber === $customer_number) {
|
||||
@@ -714,7 +730,7 @@ class users_o extends db
|
||||
$this->permissions = $perms;
|
||||
}
|
||||
|
||||
public function getUserAttributes(int $user_id = null): array
|
||||
public function getUserAttributes(?int $user_id = null): array
|
||||
{
|
||||
global $db;
|
||||
if ($user_id === null) {
|
||||
@@ -831,6 +847,53 @@ class users_o extends db
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isSuperuserNewCustomerEmailNotificationsEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
$value = $this->keys->setUser($this->id)->getValue(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
|
||||
return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public function setSuperuserNewCustomerEmailNotificationsEnabled(bool $enabled): void
|
||||
{
|
||||
self::requireSelected();
|
||||
$this->keys->setUser($this->id)->setValue(
|
||||
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS,
|
||||
$enabled ? '1' : '0'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:int, customer_number:int, display_name:string|null, email:string}>
|
||||
*/
|
||||
public function getSuperuserNewCustomerEmailNotificationRecipients(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$key = $db->escape_string(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
|
||||
$sql = "
|
||||
SELECT DISTINCT
|
||||
u.id,
|
||||
u.customer_number,
|
||||
u.display_name,
|
||||
u.email
|
||||
FROM users u
|
||||
INNER JOIN user_key_value_pairs kv
|
||||
ON kv.user_id = u.id
|
||||
AND kv.var = '$key'
|
||||
AND LOWER(kv.val) IN ('1', 'true', 'yes', 'on')
|
||||
LEFT JOIN groups_permissions gp
|
||||
ON gp.group_id = u.group_id
|
||||
AND gp.permission = 'superuser'
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.email IS NOT NULL
|
||||
AND u.email <> ''
|
||||
AND (u.group_id = 1 OR gp.id IS NOT NULL)
|
||||
";
|
||||
|
||||
return $db->fetch_all($db->query($sql));
|
||||
}
|
||||
|
||||
public function setOpenInvoiceDraft(int $draftInvoiceNumber): void
|
||||
{
|
||||
// Set the open invoice draft (key = 'open_invoice_draft')
|
||||
@@ -1056,7 +1119,7 @@ class users_o extends db
|
||||
* Set the password for the user
|
||||
* @throws Exception If the user is not selected
|
||||
*/
|
||||
public function setPassword(string $password = null): void
|
||||
public function setPassword(?string $password = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
global $db;
|
||||
|
||||
@@ -3043,6 +3043,7 @@ paths:
|
||||
wash_certificate_email: {type: string}
|
||||
sms_notifications_enabled: {type: boolean}
|
||||
email_notifications_enabled: {type: boolean}
|
||||
superuser_new_customer_email_notifications_enabled: {type: boolean}
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -6265,6 +6266,33 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/order-bookings/completion-confirmation/resend:
|
||||
post:
|
||||
tags:
|
||||
- Bookings
|
||||
summary: Resend order booking completion confirmation
|
||||
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
|
||||
operationId: resendOrderBookingCompletionConfirmation
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: {type: integer}
|
||||
responses:
|
||||
'200':
|
||||
description: Completion confirmation resent successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
|
||||
/order-bookings/complete:
|
||||
post:
|
||||
tags:
|
||||
@@ -7434,7 +7462,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reg, type, wash_subscription]
|
||||
required: [reg, type]
|
||||
properties:
|
||||
reg:
|
||||
type: string
|
||||
@@ -7446,6 +7474,8 @@ paths:
|
||||
description: Product ID representing the vehicle wash type
|
||||
wash_subscription:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Defaults to false when omitted.
|
||||
reference:
|
||||
type: string
|
||||
maxLength: 255
|
||||
@@ -9420,6 +9450,14 @@ paths:
|
||||
license_plate:
|
||||
type: string
|
||||
description: Required for START command
|
||||
wash_type:
|
||||
type: string
|
||||
enum: [Manual, Machine]
|
||||
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
|
||||
wash_mode:
|
||||
type: string
|
||||
enum: [manual, machine]
|
||||
description: Lowercase alias for wash_type accepted by backend clients.
|
||||
customer_number:
|
||||
type: integer
|
||||
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
||||
@@ -11600,6 +11638,99 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ModuleConfigTestResponse'
|
||||
|
||||
/slack/config:
|
||||
get:
|
||||
tags: [Config]
|
||||
summary: Get Slack config
|
||||
operationId: getSlackConfig
|
||||
responses:
|
||||
'200':
|
||||
description: Slack configuration retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackConfigListResponse'
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Update Slack config
|
||||
operationId: updateSlackConfig
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
responses:
|
||||
'200':
|
||||
description: Slack configuration updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
||||
|
||||
/slack/config/test:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Test Slack customer registration webhook
|
||||
operationId: testSlackCustomerRegistrationWebhook
|
||||
responses:
|
||||
'200':
|
||||
description: Slack customer registration webhook test completed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||
'400':
|
||||
description: Slack customer registration webhook URL is not configured
|
||||
'502':
|
||||
description: Slack customer registration webhook test failed
|
||||
|
||||
/slack/config/internal-department-goal-progress:
|
||||
get:
|
||||
tags: [Config]
|
||||
summary: Get Slack internal department goal progress config
|
||||
operationId: getSlackInternalDepartmentGoalProgressConfig
|
||||
responses:
|
||||
'200':
|
||||
description: Slack internal department goal progress configuration retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Update Slack internal department goal progress config
|
||||
operationId: updateSlackInternalDepartmentGoalProgressConfig
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: Slack internal department goal progress configuration updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
|
||||
|
||||
/slack/config/internal-department-goal-progress/test:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Test Slack internal department goal progress webhook
|
||||
operationId: testSlackInternalDepartmentGoalProgressWebhook
|
||||
responses:
|
||||
'200':
|
||||
description: Slack internal department goal progress webhook test completed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackConfigTestResponse'
|
||||
'400':
|
||||
description: Slack internal department goal progress webhook URL is not configured
|
||||
'502':
|
||||
description: Slack internal department goal progress webhook test failed
|
||||
|
||||
/backups/config:
|
||||
get:
|
||||
tags: [Config]
|
||||
@@ -15114,6 +15245,64 @@ components:
|
||||
- type: integer
|
||||
required: [module, variable, type, value]
|
||||
|
||||
SlackConfigEntry:
|
||||
type: object
|
||||
properties:
|
||||
module: { type: string, enum: [Slack] }
|
||||
variable: { type: string, enum: [customer_registration_webhook_url, internal_department_goal_progress_webhook_url, internal_department_ids] }
|
||||
type: { type: string, enum: [string] }
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
example: https://hooks.slack.com/services/...
|
||||
- type: string
|
||||
example: '[1,2,3]'
|
||||
required: [module, variable, type, value]
|
||||
|
||||
SlackConfigTestResult:
|
||||
type: object
|
||||
properties:
|
||||
configured: { type: boolean }
|
||||
sent: { type: boolean }
|
||||
message: { type: string }
|
||||
required: [configured, sent, message]
|
||||
|
||||
SlackInternalDepartmentGoalProgressDepartment:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
order_priority: { type: integer }
|
||||
required: [id, name, order_priority]
|
||||
|
||||
SlackInternalDepartmentGoalProgressConfig:
|
||||
type: object
|
||||
properties:
|
||||
internal_department_goal_progress_webhook_url:
|
||||
type: string
|
||||
example: https://hooks.slack.com/services/...
|
||||
internal_department_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
example: [1, 2, 3]
|
||||
departments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressDepartment'
|
||||
required: [internal_department_goal_progress_webhook_url, internal_department_ids, departments]
|
||||
|
||||
SlackInternalDepartmentGoalProgressConfigUpdate:
|
||||
type: object
|
||||
properties:
|
||||
internal_department_goal_progress_webhook_url:
|
||||
type: string
|
||||
example: https://hooks.slack.com/services/...
|
||||
internal_department_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
example: [1, 2, 3]
|
||||
required: [internal_department_goal_progress_webhook_url, internal_department_ids]
|
||||
|
||||
BackupsConfigEntry:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15382,6 +15571,30 @@ components:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
|
||||
required: [data]
|
||||
|
||||
SlackConfigListResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
- type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
||||
required: [data]
|
||||
|
||||
SlackConfigTestResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
||||
required: [data]
|
||||
|
||||
SlackInternalDepartmentGoalProgressConfigResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfig' }
|
||||
required: [data]
|
||||
|
||||
BackupsConfigListResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||
@@ -18357,10 +18570,10 @@ components:
|
||||
path_sample_limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 200
|
||||
maximum: 2048
|
||||
default: 2048
|
||||
nullable: true
|
||||
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
||||
description: Optional cap for returned path rows. Omitted returns every projected terminal path within the state cap; larger values are capped at 2048.
|
||||
|
||||
SelfserveStudioPathOutcomesResponse:
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
parameters:
|
||||
level: 0
|
||||
paths:
|
||||
- classes
|
||||
- interfaces
|
||||
- traits
|
||||
- objects
|
||||
- modules
|
||||
- routes
|
||||
- statistics
|
||||
- tests/Unit
|
||||
- tests/Integration
|
||||
- tests/Api
|
||||
bootstrapFiles:
|
||||
- vendor/autoload.php
|
||||
tmpDir: build/phpstan
|
||||
excludePaths:
|
||||
analyse:
|
||||
- vendor
|
||||
- build
|
||||
- .phpunit.cache
|
||||
- modules/*/vendor
|
||||
- modules/*/vendor/*
|
||||
- tests/Legacy
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/classes',
|
||||
__DIR__ . '/interfaces',
|
||||
__DIR__ . '/traits',
|
||||
__DIR__ . '/objects',
|
||||
__DIR__ . '/modules',
|
||||
__DIR__ . '/routes',
|
||||
__DIR__ . '/statistics',
|
||||
__DIR__ . '/tests/Unit',
|
||||
__DIR__ . '/tests/Integration',
|
||||
__DIR__ . '/tests/Api',
|
||||
])
|
||||
->withBootstrapFiles([
|
||||
__DIR__ . '/vendor/autoload.php',
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__ . '/build',
|
||||
__DIR__ . '/vendor',
|
||||
__DIR__ . '/.phpunit.cache',
|
||||
__DIR__ . '/modules/*/vendor',
|
||||
__DIR__ . '/modules/*/vendor/*',
|
||||
__DIR__ . '/tests/Legacy',
|
||||
])
|
||||
->withPreparedSets(
|
||||
codeQuality: true,
|
||||
codingStyle: true,
|
||||
phpunitCodeQuality: true,
|
||||
);
|
||||
@@ -3,9 +3,8 @@ FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
||||
php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
|
||||
@@ -727,11 +727,20 @@ final class BrokerWebSocketClient
|
||||
throw new RuntimeException('Invalid broker websocket URL: ' . $socketUrl);
|
||||
}
|
||||
|
||||
$host = (string)$parts['host'];
|
||||
$path = (string)($parts['path'] ?? '/');
|
||||
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
|
||||
$path = substr($path, strlen('/edge-broker'));
|
||||
if ($path === '') {
|
||||
$path = '/';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'scheme' => strtolower((string)($parts['scheme'] ?? 'ws')),
|
||||
'host' => (string)$parts['host'],
|
||||
'host' => $host,
|
||||
'port' => (int)($parts['port'] ?? (((string)($parts['scheme'] ?? 'ws')) === 'wss' ? 443 : 80)),
|
||||
'path' => (string)($parts['path'] ?? '/')
|
||||
'path' => $path
|
||||
. (isset($parts['query']) && trim((string)$parts['query']) !== '' ? '?' . $parts['query'] : ''),
|
||||
];
|
||||
}
|
||||
@@ -997,9 +1006,16 @@ final class TruckwashEdgeAgent
|
||||
private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
|
||||
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
||||
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
|
||||
private const OUTBOX_REPLAY_BATCH_LIMIT = 3;
|
||||
private const OUTBOX_REPLAY_TIMEOUT_SECONDS = 3;
|
||||
private const OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS = 10;
|
||||
private const OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS = 15;
|
||||
private const MACHINE_SIGNAL_TIMEOUT_SECONDS = 3;
|
||||
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
|
||||
private const BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS = 1;
|
||||
private const LOOP_STALE_AFTER_SECONDS = 30;
|
||||
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
|
||||
private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||
|
||||
private AgentConfig $config;
|
||||
private HttpJsonClient $http;
|
||||
@@ -1017,7 +1033,9 @@ final class TruckwashEdgeAgent
|
||||
private string $stagedUpdatePath;
|
||||
private int $lastHeartbeatAt = 0;
|
||||
private int $lastMachineSignalPollAt = 0;
|
||||
private int $lastBrokerConnectedCommandPollAt = 0;
|
||||
private int $lastMachineSignalMonitorRefreshAt = 0;
|
||||
private int $lastOutboxFailureAt = 0;
|
||||
private ?array $lastControlPlaneResponse = null;
|
||||
private string $agentInstanceId;
|
||||
|
||||
@@ -1061,7 +1079,6 @@ final class TruckwashEdgeAgent
|
||||
$this->reloadConfigFromDisk();
|
||||
$this->ensureClaimed();
|
||||
$this->configureBrokerClient();
|
||||
$this->flushOutbox();
|
||||
$this->pumpBrokerTransport();
|
||||
$this->heartbeat();
|
||||
$this->pollMachineStartSignals();
|
||||
@@ -1069,12 +1086,13 @@ final class TruckwashEdgeAgent
|
||||
$this->pumpBrokerTransport();
|
||||
continue;
|
||||
}
|
||||
$brokerConnected = $this->isBrokerConnected();
|
||||
$processedManagementOperation = false;
|
||||
if (!$this->isBrokerConnected()) {
|
||||
if (!$brokerConnected) {
|
||||
$processedManagementOperation = $this->processManagementOperation();
|
||||
}
|
||||
if (!$processedManagementOperation && !$this->isBrokerConnected()) {
|
||||
$this->processCommandQueue();
|
||||
if (!$processedManagementOperation && $this->shouldPollApiCommandQueue($brokerConnected)) {
|
||||
$this->processCommandQueue($brokerConnected ? 0 : null);
|
||||
}
|
||||
$this->pumpBrokerTransport();
|
||||
$this->flushOutbox();
|
||||
@@ -1093,7 +1111,7 @@ final class TruckwashEdgeAgent
|
||||
return;
|
||||
}
|
||||
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v3');
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
@@ -1249,10 +1267,10 @@ final class TruckwashEdgeAgent
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||
'target_version' => (string)$this->config->get(
|
||||
'targetVersion',
|
||||
$this->config->get('installedVersion', 'compose-php-agent-v2')
|
||||
$this->config->get('installedVersion', 'compose-php-agent-v3')
|
||||
),
|
||||
'metadata' => array_merge([
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
@@ -1356,7 +1374,8 @@ final class TruckwashEdgeAgent
|
||||
$this->sendControlPlaneEvent(
|
||||
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
|
||||
$payload,
|
||||
'machine_signal'
|
||||
'machine_signal',
|
||||
self::MACHINE_SIGNAL_TIMEOUT_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1464,14 +1483,29 @@ final class TruckwashEdgeAgent
|
||||
return null;
|
||||
}
|
||||
|
||||
private function processCommandQueue(): void
|
||||
private function shouldPollApiCommandQueue(bool $brokerConnected): bool
|
||||
{
|
||||
if (!$brokerConnected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (($now - $this->lastBrokerConnectedCommandPollAt) < self::BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->lastBrokerConnectedCommandPollAt = $now;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function processCommandQueue(?int $waitSecondsOverride = null): void
|
||||
{
|
||||
$gatewayId = (int)$this->config->get('gatewayId');
|
||||
if ($gatewayId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20);
|
||||
$waitSeconds = $waitSecondsOverride ?? (int)$this->config->get('operationPollTimeoutSeconds', 20);
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
@@ -1948,7 +1982,7 @@ final class TruckwashEdgeAgent
|
||||
|
||||
return [
|
||||
'applied' => false,
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||
'staged_version' => $targetVersion,
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => $stagedAt,
|
||||
@@ -2010,26 +2044,156 @@ final class TruckwashEdgeAgent
|
||||
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
|
||||
$channel = (int)($request['channel'] ?? 0);
|
||||
$on = (bool)($request['on'] ?? false);
|
||||
$toggleAfter = $this->resolveRelayToggleAfterSeconds($request);
|
||||
$deviceGeneration = $this->resolveShellyCommandGeneration($request);
|
||||
$workerPayload = [
|
||||
'local_ip' => $localIp,
|
||||
'channel' => $channel,
|
||||
'on' => $on,
|
||||
];
|
||||
if ($toggleAfter !== null) {
|
||||
$workerPayload['toggle_after'] = $toggleAfter;
|
||||
$workerPayload['toggleAfter'] = $toggleAfter;
|
||||
}
|
||||
if ($deviceGeneration !== null) {
|
||||
$workerPayload['device_generation'] = $deviceGeneration;
|
||||
$workerPayload['deviceGeneration'] = $deviceGeneration;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->workerHttp->post('/relay/switch', [
|
||||
'local_ip' => $localIp,
|
||||
'channel' => $channel,
|
||||
'on' => $on,
|
||||
], 8) ?? [];
|
||||
return $this->workerHttp->post('/relay/switch', $workerPayload, 8) ?? [];
|
||||
} catch (Throwable) {
|
||||
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
|
||||
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
|
||||
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
|
||||
$runRpcSwitch = fn(): array => $this->http->getJson(
|
||||
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery),
|
||||
8
|
||||
);
|
||||
$runLegacySwitch = fn(): array => $this->http->getJson(
|
||||
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery),
|
||||
8
|
||||
);
|
||||
|
||||
if ($toggleAfter !== null) {
|
||||
$attempts = $deviceGeneration === 1
|
||||
? [$runLegacySwitch, $runRpcSwitch]
|
||||
: [$runRpcSwitch, $runLegacySwitch];
|
||||
$lastError = null;
|
||||
foreach ($attempts as $attempt) {
|
||||
try {
|
||||
$attempt();
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
} catch (Throwable $throwable) {
|
||||
$lastError = $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastError ?? new RuntimeException('Unable to switch relay');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->http->getJson($rpcUrl, 8);
|
||||
$runRpcSwitch();
|
||||
} catch (Throwable) {
|
||||
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
|
||||
$this->http->getJson($legacyUrl, 8);
|
||||
$runLegacySwitch();
|
||||
}
|
||||
|
||||
return $this->fetchShellyState($localIp, $channel);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRelayToggleAfterSeconds(array $request): ?int
|
||||
{
|
||||
$configured = $request['toggleAfter'] ?? $request['toggle_after'] ?? $request['timer'] ?? null;
|
||||
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = (int)floor((float)$configured);
|
||||
if ($seconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return min($seconds, self::MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||
}
|
||||
|
||||
private function normalizeShellyDeviceGeneration(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
|
||||
$generation = (int)$value;
|
||||
return $generation > 0 ? $generation : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function inferShellyDeviceGenerationFromString(mixed $value): ?int
|
||||
{
|
||||
$normalized = trim((string)$value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
|
||||
return (int)$explicit[1];
|
||||
}
|
||||
|
||||
$upper = strtoupper($normalized);
|
||||
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
|
||||
return (int)$series[1];
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveShellyCommandGeneration(array $request): ?int
|
||||
{
|
||||
foreach ([
|
||||
$request['gen'] ?? null,
|
||||
$request['generation'] ?? null,
|
||||
$request['deviceGeneration'] ?? null,
|
||||
$request['device_generation'] ?? null,
|
||||
is_array($request['capabilities'] ?? null) ? ($request['capabilities']['generation'] ?? null) : null,
|
||||
] as $candidate) {
|
||||
$generation = $this->normalizeShellyDeviceGeneration($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$request['model'] ?? null,
|
||||
$request['deviceModel'] ?? null,
|
||||
$request['device_model'] ?? null,
|
||||
$request['type'] ?? null,
|
||||
$request['deviceType'] ?? null,
|
||||
$request['device_type'] ?? null,
|
||||
$request['app'] ?? null,
|
||||
$request['name'] ?? null,
|
||||
$request['deviceName'] ?? null,
|
||||
$request['device_name'] ?? null,
|
||||
$request['deviceId'] ?? null,
|
||||
$request['device_id'] ?? null,
|
||||
$request['relayId'] ?? null,
|
||||
$request['relay_id'] ?? null,
|
||||
$request['mac'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->inferShellyDeviceGenerationFromString($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
|
||||
{
|
||||
$directory = dirname($path);
|
||||
@@ -2321,12 +2485,14 @@ final class TruckwashEdgeAgent
|
||||
|
||||
private function flushOutbox(): void
|
||||
{
|
||||
$items = $this->stateStore->queuedItems(25);
|
||||
if ($this->shouldSkipOutboxReplay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = $this->stateStore->queuedItems(self::OUTBOX_REPLAY_BATCH_LIMIT);
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$timeoutSeconds = (string)($item['type'] ?? '') === 'operation_complete'
|
||||
? self::OPERATION_COMPLETE_TIMEOUT_SECONDS
|
||||
: 20;
|
||||
$timeoutSeconds = $this->outboxReplayTimeoutSeconds((string)($item['type'] ?? ''));
|
||||
$endpoint = (string)$item['endpoint'];
|
||||
$payload = is_array($item['payload'] ?? null) ? (array)$item['payload'] : [];
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
@@ -2334,8 +2500,10 @@ final class TruckwashEdgeAgent
|
||||
$this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||
}
|
||||
$this->stateStore->removeOutboxItem((int)$item['id']);
|
||||
$this->lastOutboxFailureAt = 0;
|
||||
$this->recordSuccessfulSync();
|
||||
} catch (Throwable $throwable) {
|
||||
$this->lastOutboxFailureAt = time();
|
||||
$this->recordTransportFailure(
|
||||
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
|
||||
$throwable
|
||||
@@ -2345,6 +2513,19 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldSkipOutboxReplay(): bool
|
||||
{
|
||||
return $this->lastOutboxFailureAt > 0
|
||||
&& (time() - $this->lastOutboxFailureAt) < self::OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS;
|
||||
}
|
||||
|
||||
private function outboxReplayTimeoutSeconds(string $type): int
|
||||
{
|
||||
return $type === 'operation_complete'
|
||||
? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS
|
||||
: self::OUTBOX_REPLAY_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
private function probeWorkerHealth(): array
|
||||
{
|
||||
try {
|
||||
@@ -2433,7 +2614,7 @@ final class TruckwashEdgeAgent
|
||||
];
|
||||
}
|
||||
|
||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): bool
|
||||
{
|
||||
$this->lastControlPlaneResponse = null;
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
@@ -2442,7 +2623,7 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->http->post($endpoint, $payload, 20);
|
||||
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
|
||||
$this->recordSuccessfulSync();
|
||||
return true;
|
||||
@@ -2477,7 +2658,9 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
$current = trim((string)$this->config->get('brokerUrl'));
|
||||
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
|
||||
$currentNormalized = $this->normalizeBrokerUrlForComparison($current);
|
||||
$nextNormalized = $this->normalizeBrokerUrlForComparison($brokerUrl);
|
||||
if ($currentNormalized !== null && $currentNormalized === $nextNormalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2487,6 +2670,38 @@ final class TruckwashEdgeAgent
|
||||
$this->logger->info('Updated broker URL from control plane heartbeat response.');
|
||||
}
|
||||
|
||||
private function normalizeBrokerUrlForComparison(?string $value): ?string
|
||||
{
|
||||
$trimmed = rtrim(trim((string)($value ?? '')), '/');
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'https://')) {
|
||||
$trimmed = 'wss://' . substr($trimmed, 8);
|
||||
} elseif (str_starts_with($trimmed, 'http://')) {
|
||||
$trimmed = 'ws://' . substr($trimmed, 7);
|
||||
} elseif (!str_starts_with($trimmed, 'ws://') && !str_starts_with($trimmed, 'wss://')) {
|
||||
$trimmed = 'ws://' . $trimmed;
|
||||
}
|
||||
|
||||
$parts = parse_url($trimmed);
|
||||
if (!is_array($parts) || empty($parts['host'])) {
|
||||
return strtolower($trimmed);
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)($parts['scheme'] ?? 'ws'));
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'wss' ? 443 : 80);
|
||||
$path = rtrim((string)($parts['path'] ?? ''), '/');
|
||||
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
|
||||
$path = rtrim(substr($path, strlen('/edge-broker')), '/');
|
||||
}
|
||||
|
||||
$isDefaultPort = ($scheme === 'wss' && $port === 443) || ($scheme !== 'wss' && $port === 80);
|
||||
return $scheme . '://' . $host . ($isDefaultPort ? '' : ':' . $port) . ($path === '' ? '' : $path);
|
||||
}
|
||||
|
||||
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
|
||||
{
|
||||
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
||||
@@ -2796,6 +3011,13 @@ final class TruckwashEdgeAgent
|
||||
]);
|
||||
}
|
||||
|
||||
if (preg_match('#/edge-agent/gateways/\d+/selfserve/machine-signal$#', $endpoint)) {
|
||||
return $this->sendBrokerMessage([
|
||||
'type' => 'MACHINE_SIGNAL',
|
||||
'payload' => $this->stripAgentAuthentication($payload),
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2890,7 +3112,10 @@ final class TruckwashEdgeAgent
|
||||
{
|
||||
$reloaded = AgentConfig::load($this->config->path);
|
||||
$this->config = $reloaded;
|
||||
$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->configureBrokerClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
|
||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false) { exit(1); } $$now=time(); exit(($$now - $$loopAt) <= 120 ? 0 : 1);'",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -31,14 +31,17 @@ compose_cmd() {
|
||||
}
|
||||
|
||||
print_compose_diagnostics() {
|
||||
log "docker ps --format '{{.Names}} {{.Status}}'"
|
||||
docker ps --format '{{.Names}} {{.Status}}' || true
|
||||
local compose_project_name
|
||||
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
||||
|
||||
log "compose ps"
|
||||
compose_cmd -f "$COMPOSE_FILE" ps || true
|
||||
log "docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'"
|
||||
docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}' || true
|
||||
|
||||
log "compose logs --tail=80"
|
||||
compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true
|
||||
log "compose ps -a --no-trunc"
|
||||
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" ps -a --no-trunc || true
|
||||
|
||||
log "compose logs --no-color --no-log-prefix --tail=120"
|
||||
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" logs --no-color --no-log-prefix --tail=120 || true
|
||||
}
|
||||
|
||||
config_value() {
|
||||
@@ -61,6 +64,32 @@ config_value() {
|
||||
' "$CONFIG_PATH" "$key" "$fallback"
|
||||
}
|
||||
|
||||
staged_update_value() {
|
||||
local key="$1"
|
||||
local fallback="${2:-}"
|
||||
php -r '
|
||||
$path = $argv[1];
|
||||
$key = $argv[2];
|
||||
$fallback = $argv[3];
|
||||
if (!is_file($path)) {
|
||||
echo $fallback;
|
||||
exit(0);
|
||||
}
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
|
||||
echo $fallback;
|
||||
exit(0);
|
||||
}
|
||||
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
|
||||
' "$STAGED_UPDATE_PATH" "$key" "$fallback"
|
||||
}
|
||||
|
||||
staged_update_is_applied() {
|
||||
local status
|
||||
status="$(staged_update_value status '')"
|
||||
[ "$status" = "APPLIED" ]
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
|
||||
}
|
||||
@@ -103,7 +132,10 @@ ensure_stack_env() {
|
||||
|
||||
within_update_window() {
|
||||
local window
|
||||
window="$(config_value updateWindow '02:00-04:00')"
|
||||
window="$(staged_update_value update_window '')"
|
||||
if [ -z "$window" ]; then
|
||||
window="$(config_value updateWindow '02:00-04:00')"
|
||||
fi
|
||||
php -r '
|
||||
$window = $argv[1];
|
||||
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
|
||||
@@ -283,6 +315,10 @@ case "$ACTION" in
|
||||
reconcile_stack
|
||||
;;
|
||||
reconcile)
|
||||
if [ -f "$STAGED_UPDATE_PATH" ] && staged_update_is_applied; then
|
||||
log "Staged update already applied; nothing to reconcile"
|
||||
exit 0
|
||||
fi
|
||||
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
|
||||
log "Update is staged but outside the maintenance window; keeping current stack running"
|
||||
exit 0
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
const WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||
|
||||
function worker_json_response(int $status, array $payload): void
|
||||
{
|
||||
http_response_code($status);
|
||||
@@ -156,17 +158,173 @@ function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array
|
||||
}
|
||||
}
|
||||
|
||||
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
|
||||
function worker_normalize_shelly_device_generation(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
|
||||
$generation = (int)$value;
|
||||
return $generation > 0 ? $generation : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function worker_infer_shelly_device_generation_from_string(mixed $value): ?int
|
||||
{
|
||||
$normalized = trim((string)$value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
|
||||
return (int)$explicit[1];
|
||||
}
|
||||
|
||||
$upper = strtoupper($normalized);
|
||||
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
|
||||
return (int)$series[1];
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function worker_resolve_shelly_command_generation(array $command): ?int
|
||||
{
|
||||
foreach ([
|
||||
$command['gen'] ?? null,
|
||||
$command['generation'] ?? null,
|
||||
$command['deviceGeneration'] ?? null,
|
||||
$command['device_generation'] ?? null,
|
||||
is_array($command['capabilities'] ?? null) ? ($command['capabilities']['generation'] ?? null) : null,
|
||||
] as $candidate) {
|
||||
$generation = worker_normalize_shelly_device_generation($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$command['model'] ?? null,
|
||||
$command['deviceModel'] ?? null,
|
||||
$command['device_model'] ?? null,
|
||||
$command['type'] ?? null,
|
||||
$command['deviceType'] ?? null,
|
||||
$command['device_type'] ?? null,
|
||||
$command['app'] ?? null,
|
||||
$command['name'] ?? null,
|
||||
$command['deviceName'] ?? null,
|
||||
$command['device_name'] ?? null,
|
||||
$command['deviceId'] ?? null,
|
||||
$command['device_id'] ?? null,
|
||||
$command['relayId'] ?? null,
|
||||
$command['relay_id'] ?? null,
|
||||
$command['mac'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = worker_infer_shelly_device_generation_from_string($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function worker_resolve_relay_toggle_after_seconds(array $command): ?int
|
||||
{
|
||||
$configured = $command['toggleAfter'] ?? $command['toggle_after'] ?? $command['timer'] ?? null;
|
||||
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = (int)floor((float)$configured);
|
||||
if ($seconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return min($seconds, WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||
}
|
||||
|
||||
function worker_switch_shelly_state(string $localIp, int $channel, bool $on, array $command = []): array
|
||||
{
|
||||
if ($localIp === '') {
|
||||
throw new RuntimeException('Missing Shelly IP address');
|
||||
}
|
||||
|
||||
$toggleAfter = worker_resolve_relay_toggle_after_seconds($command);
|
||||
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
|
||||
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
|
||||
$runRpcSwitch = static fn(): array => worker_http_get_json(
|
||||
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery)
|
||||
);
|
||||
$runLegacySwitch = static fn(): array => worker_http_get_json(
|
||||
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery)
|
||||
);
|
||||
|
||||
if ($toggleAfter !== null) {
|
||||
$attempts = worker_resolve_shelly_command_generation($command) === 1
|
||||
? [$runLegacySwitch, $runRpcSwitch]
|
||||
: [$runRpcSwitch, $runLegacySwitch];
|
||||
$lastError = null;
|
||||
foreach ($attempts as $attempt) {
|
||||
try {
|
||||
$attempt();
|
||||
return worker_fetch_shelly_state($localIp, $channel);
|
||||
} catch (Throwable $throwable) {
|
||||
$lastError = $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastError ?? new RuntimeException('Unable to switch relay');
|
||||
}
|
||||
|
||||
try {
|
||||
worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'));
|
||||
$runRpcSwitch();
|
||||
} catch (Throwable) {
|
||||
worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'));
|
||||
$runLegacySwitch();
|
||||
}
|
||||
|
||||
return worker_fetch_shelly_state($localIp, $channel);
|
||||
}
|
||||
|
||||
function worker_normalize_relay_commands(array $body): array
|
||||
{
|
||||
$commands = $body['commands'] ?? [];
|
||||
return is_array($commands) ? array_values($commands) : [];
|
||||
}
|
||||
|
||||
function worker_relay_command_result(array $command, callable $handler): array
|
||||
{
|
||||
$target = trim((string)($command['target'] ?? $command['relay'] ?? ''));
|
||||
$relayId = trim((string)($command['relayId'] ?? $command['relay_id'] ?? ''));
|
||||
|
||||
try {
|
||||
$localIp = trim((string)($command['local_ip'] ?? $command['localIp'] ?? ''));
|
||||
$channel = (int)($command['channel'] ?? 0);
|
||||
return [
|
||||
'target' => $target,
|
||||
'relayId' => $relayId,
|
||||
'relay_id' => $relayId,
|
||||
'ok' => true,
|
||||
'payload' => $handler($localIp, $channel, $command),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'target' => $target,
|
||||
'relayId' => $relayId,
|
||||
'relay_id' => $relayId,
|
||||
'ok' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
||||
$body = worker_read_json_body();
|
||||
@@ -244,7 +402,50 @@ try {
|
||||
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
|
||||
$channel = (int)($body['channel'] ?? 0);
|
||||
$on = (bool)($body['on'] ?? false);
|
||||
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on));
|
||||
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on, $body));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/batch-status') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$results = array_map(
|
||||
fn(array $command): array => worker_relay_command_result(
|
||||
$command,
|
||||
fn(string $localIp, int $channel): array => worker_fetch_shelly_state($localIp, $channel)
|
||||
),
|
||||
worker_normalize_relay_commands($body)
|
||||
);
|
||||
worker_json_response(200, [
|
||||
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
|
||||
'results' => $results,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/relay/batch-switch') {
|
||||
if (!worker_require_authorization()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$results = array_map(
|
||||
fn(array $command): array => worker_relay_command_result(
|
||||
$command,
|
||||
fn(string $localIp, int $channel, array $entry): array => worker_switch_shelly_state(
|
||||
$localIp,
|
||||
$channel,
|
||||
(bool)($entry['on'] ?? false),
|
||||
$entry
|
||||
)
|
||||
),
|
||||
worker_normalize_relay_commands($body)
|
||||
);
|
||||
worker_json_response(200, [
|
||||
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
|
||||
'results' => $results,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -398,12 +398,18 @@ class InvoicingPeriodRoute
|
||||
$limit = min(500, $limit);
|
||||
}
|
||||
|
||||
$allowedFlagTabs = ['all' => true, 'red' => true, 'yellow' => true, 'none' => true, 'filters' => true];
|
||||
$flagTab = trim((string)($parameters['flagTab'] ?? 'all'));
|
||||
if ($flagTab === '' || !isset($allowedFlagTabs[$flagTab])) {
|
||||
$flagTab = 'all';
|
||||
}
|
||||
|
||||
return [
|
||||
'periodView' => $periodView,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'search' => trim((string)($parameters['search'] ?? '')),
|
||||
'flagTab' => trim((string)($parameters['flagTab'] ?? 'all')),
|
||||
'flagTab' => $flagTab,
|
||||
'includeRequiresAction' => self::parsePeriodBooleanOption(
|
||||
$parameters['includeRequiresAction'] ?? null,
|
||||
true
|
||||
@@ -506,25 +512,11 @@ class InvoicingPeriodRoute
|
||||
$types[$viewName] = array_values(array_filter(
|
||||
is_array($entries) ? $entries : [],
|
||||
static function (array $customer) use ($flagTab): bool {
|
||||
$hasManual = false;
|
||||
$hasAutomatic = false;
|
||||
if (is_array($customer['flags'] ?? null)) {
|
||||
foreach ($customer['flags'] as $flag) {
|
||||
if (!empty($flag['order_id']) || !empty($flag['invoice_collection_id'])) {
|
||||
continue;
|
||||
}
|
||||
if ($flag['is_manual'] ?? ($flag['source'] ?? '') === 'manual') {
|
||||
$hasManual = true;
|
||||
} else {
|
||||
$hasAutomatic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tab = 'none';
|
||||
if ($hasManual) {
|
||||
$flagCounts = self::getActivePeriodFlagCounts($customer);
|
||||
if ($flagCounts['manual'] > 0) {
|
||||
$tab = 'red';
|
||||
} elseif ($hasAutomatic) {
|
||||
} elseif ($flagCounts['automatic'] > 0) {
|
||||
$tab = 'yellow';
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\economic;
|
||||
use classes\email;
|
||||
use classes\release_manager;
|
||||
use classes\recaptcha;
|
||||
use classes\slack;
|
||||
use classes\totp;
|
||||
use classes\virkdata;
|
||||
use classes\webauthn;
|
||||
@@ -23,6 +24,72 @@ class authRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
|
||||
{
|
||||
return 'passkey_challenge_principal:' . $challengeToken;
|
||||
}
|
||||
|
||||
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
|
||||
}
|
||||
|
||||
private function getPasskeyChallengePrincipal(string $challengeToken): string
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return 'discoverable';
|
||||
}
|
||||
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
|
||||
? $principalType
|
||||
: 'discoverable';
|
||||
}
|
||||
|
||||
private function clearPasskeyChallengePrincipal(string $challengeToken): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||
}
|
||||
|
||||
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause(
|
||||
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
|
||||
);
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
if (isset($o['transports'])) {
|
||||
$decoded = json_decode($o['transports'], true);
|
||||
$transports = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
return [
|
||||
'type' => 'public-key',
|
||||
'id' => $o['credential_id'] ?? null,
|
||||
'transports' => $transports,
|
||||
];
|
||||
});
|
||||
|
||||
if (isset($list['items']) && is_array($list['items'])) {
|
||||
$list = $list['items'];
|
||||
}
|
||||
|
||||
return is_array($list)
|
||||
? array_values(array_filter($list, function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->post('/auth/login', function () {
|
||||
@@ -408,15 +475,7 @@ class authRoute
|
||||
* Check if the cvr already exists
|
||||
*/
|
||||
$economic = new economic();
|
||||
$economic_response = ($economic->customers->customers->search([
|
||||
'corporateIdentificationNumber' => (string)$cvr,
|
||||
], [
|
||||
'skipPages' => 0,
|
||||
'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000.
|
||||
])->collection);
|
||||
if (!is_array($economic_response)) {
|
||||
$economic_response = [];
|
||||
}
|
||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr);
|
||||
|
||||
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
||||
@@ -447,22 +506,66 @@ class authRoute
|
||||
);
|
||||
}
|
||||
|
||||
// Get the CVR company information used for the e-conomic customer payload.
|
||||
$companyInformation = null;
|
||||
try {
|
||||
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
|
||||
'phase' => 'cvr_lookup',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$name = trim((string)($companyInformation->name ?? ''));
|
||||
if ($name === '') {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
|
||||
'phase' => 'cvr_lookup',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
]);
|
||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($localUserExists) {
|
||||
$response->error('Company phone number already registered', 400);
|
||||
}
|
||||
|
||||
// Get the CVR company information used for the e-conomic customer payload.
|
||||
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
|
||||
$name = (string)($companyInformation->name ?? '');
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
$name,
|
||||
(int)$cvr,
|
||||
(string)$invoiceEmail,
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
);
|
||||
try {
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
$name,
|
||||
(int)$cvr,
|
||||
(string)$invoiceEmail,
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
||||
$economic,
|
||||
(string)$cvr,
|
||||
$companyPhone,
|
||||
(string)$invoiceEmail
|
||||
);
|
||||
|
||||
if ($recoveredCustomer !== null) {
|
||||
$response->success($recoveredCustomer, 200);
|
||||
}
|
||||
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [
|
||||
'phase' => 'create',
|
||||
'cvr' => (string)$cvr,
|
||||
'requestedCustomerNumber' => $companyPhone,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$response->error('Failed to create customer in e-conomic.', 502);
|
||||
}
|
||||
|
||||
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
||||
@@ -494,7 +597,7 @@ class authRoute
|
||||
);
|
||||
}
|
||||
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone);
|
||||
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
||||
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||
$response->success($result, 201);
|
||||
});
|
||||
@@ -533,7 +636,8 @@ class authRoute
|
||||
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
||||
|
||||
$subject = 'Adgangskode nulstilling';
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
||||
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
|
||||
|
||||
try {
|
||||
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
||||
@@ -587,11 +691,53 @@ class authRoute
|
||||
global $response;
|
||||
$this->requireRecaptcha();
|
||||
|
||||
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
|
||||
if ($principal_type === '') {
|
||||
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
|
||||
}
|
||||
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
|
||||
$response->error('Invalid principal_type', 400);
|
||||
}
|
||||
|
||||
$customer_number = self::getParameter('customer_number');
|
||||
$user_id = 0;
|
||||
$allowCredentials = [];
|
||||
|
||||
if ($customer_number !== null) {
|
||||
if ($principal_type === 'subuser') {
|
||||
$subuser = null;
|
||||
if (self::isParametersSet(['subuser_id'])) {
|
||||
$subuser_id = (int)self::getParameter('subuser_id');
|
||||
self::requireType($subuser_id, $this->type_int());
|
||||
self::requireMinValue($subuser_id, 1);
|
||||
$candidate = (new subusers_o())->select($subuser_id);
|
||||
if ($candidate->exists()) {
|
||||
$candidate->getObjectProperties();
|
||||
$subuser = $candidate;
|
||||
}
|
||||
} elseif (self::isParametersSet(['username'])) {
|
||||
$username = (string)self::getParameter('username');
|
||||
self::requireType($username, $this->type_string());
|
||||
self::requireMinLength('username', 3);
|
||||
self::requireMaxLength('username', 255);
|
||||
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
||||
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
|
||||
$phone_country_code = (int)self::getParameter('phone_country_code');
|
||||
$phone = (int)self::getParameter('phone');
|
||||
self::requireType($phone_country_code, $this->type_int());
|
||||
self::requireMinLength('phone_country_code', 1);
|
||||
self::requireMaxLength('phone_country_code', 3);
|
||||
self::requireType($phone, $this->type_int());
|
||||
self::requireMinLength('phone', 4);
|
||||
self::requireMaxLength('phone', 15);
|
||||
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
||||
}
|
||||
|
||||
if ($subuser !== null) {
|
||||
$user_id = (int)$subuser->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||
}
|
||||
} elseif ($customer_number !== null) {
|
||||
$principal_type = 'user';
|
||||
$customer_number = (int)$customer_number;
|
||||
self::requireType($customer_number, $this->type_int());
|
||||
self::requireMinValue($customer_number, 1);
|
||||
@@ -601,33 +747,7 @@ class authRoute
|
||||
|
||||
if ($user->exists()) {
|
||||
$user_id = (int)$user->id;
|
||||
|
||||
// Load passkeys for this user (non-subuser)
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
if (isset($o['transports'])) {
|
||||
$decoded = json_decode($o['transports'], true);
|
||||
$transports = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
return [
|
||||
'type' => 'public-key',
|
||||
'id' => $o['credential_id'] ?? null,
|
||||
'transports' => $transports,
|
||||
];
|
||||
});
|
||||
|
||||
// Ensure we return a simple array of credentials (without pagination wrapper)
|
||||
if (isset($list['items']) && is_array($list['items'])) {
|
||||
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
} elseif (is_array($list)) {
|
||||
$allowCredentials = array_values(array_filter($list, function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
}
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +764,10 @@ class authRoute
|
||||
|
||||
// Create an ephemeral token to bind the challenge to the (potential) user
|
||||
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
||||
$this->setPasskeyChallengePrincipal(
|
||||
$challenge_token,
|
||||
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
|
||||
);
|
||||
|
||||
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
|
||||
@@ -695,6 +819,7 @@ class authRoute
|
||||
if ($token_type !== 'PASSKEY_CHALLENGE') {
|
||||
$response->error('Invalid token type', 401);
|
||||
}
|
||||
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
|
||||
|
||||
// Determine rpId/host
|
||||
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
||||
@@ -730,7 +855,17 @@ class authRoute
|
||||
// Success → issue session token accordingly and delete the challenge token
|
||||
$issued_to_user_id = (int)$passkey->user_id->value();
|
||||
$is_subuser = (bool)$passkey->is_subuser->value();
|
||||
if (
|
||||
($challengePrincipalType === 'subuser' && !$is_subuser)
|
||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||
) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
$response->error('Invalid credential', 401);
|
||||
}
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
|
||||
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
|
||||
|
||||
@@ -757,6 +892,49 @@ class authRoute
|
||||
return count($rows) > 0;
|
||||
}
|
||||
|
||||
private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array
|
||||
{
|
||||
$economic_response = ($economic->customers->customers->search([
|
||||
'corporateIdentificationNumber' => $cvr,
|
||||
], [
|
||||
'skipPages' => 0,
|
||||
'pageSize' => 1,
|
||||
])->collection);
|
||||
|
||||
return is_array($economic_response) ? $economic_response : [];
|
||||
}
|
||||
|
||||
private function recoverRegistrationAfterCreateFailure(
|
||||
economic $economic,
|
||||
string $cvr,
|
||||
int $customerNumber,
|
||||
string $invoiceEmail
|
||||
): ?object {
|
||||
// The upstream POST can commit before the client receives a validation/transport error.
|
||||
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
||||
try {
|
||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
||||
} catch (Exception $searchException) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [
|
||||
'phase' => 'create_recovery',
|
||||
'cvr' => $cvr,
|
||||
'requestedCustomerNumber' => $customerNumber,
|
||||
'message' => $searchException->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber);
|
||||
if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
||||
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
||||
|
||||
return $matchingEconomicCustomer;
|
||||
}
|
||||
|
||||
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||
{
|
||||
foreach ($customers as $customer) {
|
||||
@@ -784,19 +962,46 @@ class authRoute
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function bootstrapLocalCustomerOrFail(int $customerNumber): users_o
|
||||
private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
|
||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||
return $customer;
|
||||
$customer = new users_o();
|
||||
try {
|
||||
$customer = $customer->getUserByCustomerNumber($customerNumber);
|
||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||
return $customer;
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
$economicCustomer !== null
|
||||
&& $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber
|
||||
&& method_exists($customer, 'importCustomerFromEconomicCustomerData')
|
||||
) {
|
||||
try {
|
||||
$importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer);
|
||||
if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) {
|
||||
return $importedCustomer;
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
]);
|
||||
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
||||
throw new Exception('Customer was created in e-conomic but could not be imported locally.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -810,6 +1015,24 @@ class authRoute
|
||||
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
||||
$email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail);
|
||||
$email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail);
|
||||
|
||||
try {
|
||||
$email->sendNewCustomerRegistrationNotifications($customerNumber);
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
(new slack())->send_customer_registration_notification($customerNumber);
|
||||
} catch (Exception $exception) {
|
||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
|
||||
'customerNumber' => $customerNumber,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function logRegisterCvrIssue(string $action, array $context): void
|
||||
|
||||
@@ -6,6 +6,8 @@ use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\shelly_relay_inventory;
|
||||
use dynamicimages\images\machine_1;
|
||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
||||
use modules\selfserve\selfserve_c;
|
||||
use objects\categories_o;
|
||||
use objects\department_lanes_o;
|
||||
use objects\department_selfserve_tasks_o;
|
||||
@@ -16,6 +18,8 @@ class departmentLanesRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH = 1600;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/lanes/status-toggles', function () {
|
||||
@@ -219,6 +223,7 @@ class departmentLanesRoute
|
||||
$response->error('No dynamic image configured for this lane', 404);
|
||||
}
|
||||
$dynamic_image_id = (int)$dynamic_image_id;
|
||||
$dynamic_image_size = self::getSelfServeDynamicImageSizeMode();
|
||||
|
||||
// Parse optional params
|
||||
$buttons = null;
|
||||
@@ -266,27 +271,21 @@ class departmentLanesRoute
|
||||
// Cache check
|
||||
$cacheKey = null;
|
||||
if (defined('redis')) {
|
||||
// Cache only the default image variant to avoid unbounded cache key growth
|
||||
// from request-controlled parameters (buttons/current_step/etc.).
|
||||
$isDefaultVariant = $buttons === null
|
||||
&& $current_step === 0
|
||||
&& !(bool)$only_current_step
|
||||
&& $vehicle_type === null;
|
||||
|
||||
if ($isDefaultVariant) {
|
||||
$cacheParams = [
|
||||
'department' => $department_id,
|
||||
'lane' => $lane_id,
|
||||
'dynamic_image_id' => $dynamic_image_id,
|
||||
];
|
||||
$cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams));
|
||||
$cachedImage = redis->get($cacheKey);
|
||||
if ($cachedImage) {
|
||||
header('Content-Type: image/png');
|
||||
header('Content-Length: ' . strlen($cachedImage));
|
||||
echo $cachedImage;
|
||||
exit;
|
||||
}
|
||||
$cacheKey = self::buildDynamicImageCacheKey([
|
||||
'dynamic_image_id' => $dynamic_image_id,
|
||||
'buttons' => $buttons,
|
||||
'current_step' => $current_step,
|
||||
'only_current_step' => $only_current_step,
|
||||
'vehicle_type' => $vehicle_type,
|
||||
'dynamic_image_size' => $dynamic_image_size,
|
||||
'thumb_position' => $thumb_position,
|
||||
]);
|
||||
$cachedImage = $cacheKey === null ? false : redis->get($cacheKey);
|
||||
if ($cachedImage) {
|
||||
header('Content-Type: image/png');
|
||||
header('Content-Length: ' . strlen($cachedImage));
|
||||
echo $cachedImage;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,20 +312,17 @@ class departmentLanesRoute
|
||||
// Compose and serve the image
|
||||
try {
|
||||
$image->setup();
|
||||
if ($dynamic_image_size === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
||||
$image->resizeToMaxWidth(self::RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH);
|
||||
}
|
||||
|
||||
// If caching is enabled, we need to capture the output or use export
|
||||
if ($cacheKey && defined('redis')) {
|
||||
$dataUri = $image->exportAsBase64('png');
|
||||
if (preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||
$imageData = base64_decode($matches[1]);
|
||||
if ($imageData !== false) {
|
||||
redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours
|
||||
header('Content-Type: image/png');
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$imageData = $image->exportBinary('png');
|
||||
redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours
|
||||
header('Content-Type: image/png');
|
||||
header('Content-Length: ' . strlen($imageData));
|
||||
echo $imageData;
|
||||
exit;
|
||||
}
|
||||
|
||||
$image->servePicture('png');
|
||||
@@ -527,4 +523,47 @@ class departmentLanesRoute
|
||||
|
||||
$field->set($normalized);
|
||||
}
|
||||
|
||||
private static function getSelfServeDynamicImageSizeMode(): string
|
||||
{
|
||||
try {
|
||||
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
|
||||
} catch (\Throwable) {
|
||||
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
|
||||
? $mode
|
||||
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* dynamic_image_id:int,
|
||||
* buttons:array<int|string>|null,
|
||||
* current_step:int,
|
||||
* only_current_step:bool,
|
||||
* vehicle_type:int|null,
|
||||
* dynamic_image_size:string,
|
||||
* thumb_position:int|null
|
||||
* } $variant
|
||||
*/
|
||||
private static function buildDynamicImageCacheKey(array $variant): ?string
|
||||
{
|
||||
$cacheParams = [
|
||||
'dynamic_image_id' => (int)$variant['dynamic_image_id'],
|
||||
'buttons' => $variant['buttons'],
|
||||
'current_step' => (int)$variant['current_step'],
|
||||
'only_current_step' => (bool)$variant['only_current_step'],
|
||||
'vehicle_type' => $variant['vehicle_type'],
|
||||
'dynamic_image_size' => (string)$variant['dynamic_image_size'],
|
||||
];
|
||||
|
||||
if ($variant['thumb_position'] !== null) {
|
||||
$cacheParams['thumb_position'] = (int)$variant['thumb_position'];
|
||||
}
|
||||
|
||||
$json = json_encode($cacheParams);
|
||||
return $json === false ? null : 'dynamic_image:' . md5($json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\selfserve;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\department_lanes_o;
|
||||
use objects\department_selfserve_tasks_o;
|
||||
use objects\department_selfserve_vehicle_conditions_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use traits\route_t;
|
||||
|
||||
class departmentSelfserveVehicleConditionsRoute
|
||||
@@ -116,34 +119,38 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->get('/department/selfserve/vehicle/allowed', function () {
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
|
||||
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
|
||||
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||
}
|
||||
|
||||
if ($user === null && !$has_own) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['lane_id', 'reg']);
|
||||
$lane_id = (int)self::getParameter('lane_id');
|
||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||
|
||||
$lane = $this->assertLaneAccess($user, $lane_id);
|
||||
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
|
||||
$customer_number = null;
|
||||
if (!$has_global && $has_own) {
|
||||
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
|
||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||
}
|
||||
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
||||
$flow = $this->getWashFlow();
|
||||
|
||||
if ($vehicle_type_id !== null) {
|
||||
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);
|
||||
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
||||
'create_session' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $actor_id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
||||
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
|
||||
}, [
|
||||
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
|
||||
@@ -155,26 +162,28 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->get('/department/selfserve/washes/summary', function () {
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
[$user] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
|
||||
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
|
||||
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||
}
|
||||
|
||||
if ($user === null && !$has_own) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$flow = $this->getWashFlow();
|
||||
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
||||
try {
|
||||
if (self::isParametersSet(['session_id'])) {
|
||||
$summary = $flow->getSessionSummary((int)self::getParameter('session_id'));
|
||||
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
|
||||
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
|
||||
|
||||
if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) {
|
||||
$summary = $flow->synchronizeSession(
|
||||
$refreshed_summary = $flow->synchronizeSession(
|
||||
(int)($summary['session']['lane_id'] ?? 0),
|
||||
(string)($summary['session']['reg'] ?? ''),
|
||||
isset($summary['session']['customer_number']) && $summary['session']['customer_number'] !== null
|
||||
@@ -182,8 +191,12 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
: null,
|
||||
false,
|
||||
$vehicle_type_id,
|
||||
false
|
||||
false,
|
||||
['create_session' => false]
|
||||
);
|
||||
if (!empty($refreshed_summary['session']['id'])) {
|
||||
$summary = $refreshed_summary;
|
||||
}
|
||||
}
|
||||
|
||||
$response->success($summary);
|
||||
@@ -193,18 +206,27 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
$lane_id = (int)self::getParameter('lane_id');
|
||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||
|
||||
$this->assertLaneAccess($user, $lane_id);
|
||||
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
|
||||
$customer_number = null;
|
||||
if (!$has_global && $has_own) {
|
||||
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
|
||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||
}
|
||||
|
||||
if ($vehicle_type_id !== null) {
|
||||
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);
|
||||
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
||||
'create_session' => false,
|
||||
]);
|
||||
if (empty($summary['session']['id'])) {
|
||||
try {
|
||||
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
|
||||
} catch (\RuntimeException) {
|
||||
// Keep the read-only snapshot when no previous wash exists.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
|
||||
}
|
||||
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
|
||||
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
|
||||
$response->success($summary);
|
||||
} catch (\RuntimeException $e) {
|
||||
$response->error($e->getMessage(), 404);
|
||||
@@ -219,18 +241,20 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->post('/department/selfserve/vehicle/conditions', function () {
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
|
||||
|
||||
$has_global = $user->hasPermission('add_department_selfserve_vehicle_conditions');
|
||||
$has_own = $user->hasPermission('add_own_department_selfserve_vehicle_conditions');
|
||||
$has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
|
||||
}
|
||||
|
||||
if ($user === null && !$has_own) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$department = (int)$response->getRequestParameter('department');
|
||||
$lane = (int)$response->getRequestParameter('lane');
|
||||
$reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg'));
|
||||
@@ -258,7 +282,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
$condition_o = new department_selfserve_vehicle_conditions_o();
|
||||
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
|
||||
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $actor_id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
|
||||
$response->success([
|
||||
'condition' => $condition_o->asArray(),
|
||||
'selfserve' => $summary,
|
||||
@@ -438,6 +462,24 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
return new selfserve_wash_flow();
|
||||
}
|
||||
|
||||
private function getAuthenticatedSelfServePrincipal(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$auth = new authentication();
|
||||
$user = $auth->get_user();
|
||||
if ($user !== false) {
|
||||
return [$user, (int)$user->id];
|
||||
}
|
||||
|
||||
$subuser = $auth->get_subuser();
|
||||
if ($subuser !== false) {
|
||||
return [null, (int)$subuser->id];
|
||||
}
|
||||
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
private function resolveVehicleTypeIdFromQuery(): ?int
|
||||
{
|
||||
$rawVehicleType = null;
|
||||
@@ -490,6 +532,10 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
}
|
||||
|
||||
$session = is_array($summary['session'] ?? null) ? $summary['session'] : [];
|
||||
if (($session['completed_at'] ?? null) !== null || selfserve_wash_sessions_o::isTerminalStatus($session['status'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionVehicleTypeId = isset($session['vehicle_type_id']) && $session['vehicle_type_id'] !== null
|
||||
? (int)$session['vehicle_type_id']
|
||||
: null;
|
||||
@@ -527,7 +573,13 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function assertLaneAccess(object $user, int $laneId): department_lanes_o
|
||||
private function assertLaneAccess(
|
||||
?object $user,
|
||||
int $laneId,
|
||||
bool $hasGlobalPermission = true,
|
||||
bool $hasOwnPermission = false,
|
||||
string $elevatedPermission = 'list_department_selfserve_vehicle_conditions'
|
||||
): department_lanes_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
@@ -536,24 +588,89 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
$response->error('Department lane not found', 404);
|
||||
}
|
||||
|
||||
if ($hasGlobalPermission && $user !== null && $this->userHasLaneDepartmentAccess($user, $lane)) {
|
||||
return $lane;
|
||||
}
|
||||
|
||||
if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane)) {
|
||||
return $lane;
|
||||
}
|
||||
|
||||
if ($hasGlobalPermission) {
|
||||
$lane_department_id = (int)$lane->department->value();
|
||||
$this->forbidDepartmentAccess($lane_department_id);
|
||||
}
|
||||
|
||||
$response->forbidden([$elevatedPermission]);
|
||||
}
|
||||
|
||||
private function userHasLaneDepartmentAccess(object $user, department_lanes_o $lane): bool
|
||||
{
|
||||
$lane_department_id = (int)$lane->department->value();
|
||||
$authorized_department_ids = array_values(array_filter(
|
||||
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
||||
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 in_array($lane_department_id, $authorized_department_ids, true);
|
||||
}
|
||||
|
||||
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
|
||||
private function isCustomerSelfServeLaneEnabled(department_lanes_o $lane): bool
|
||||
{
|
||||
try {
|
||||
if (!$lane->isSelfServeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$department = (new departments_o())->select((int)$lane->department->value());
|
||||
return $department->exists() && $department->getSelfServeEnabled();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function summaryBelongsToCustomer(?object $user, array $summary): bool
|
||||
{
|
||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
||||
if ($session_customer_number !== null && (int)$session_customer_number === $customer_number) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$reg = (string)($summary['session']['reg'] ?? '');
|
||||
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
||||
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === $customer_number;
|
||||
}
|
||||
|
||||
private function summaryDepartmentId(array $summary): int
|
||||
{
|
||||
return (int)($summary['lane']['department'] ?? $summary['session']['department_id'] ?? 0);
|
||||
}
|
||||
|
||||
private function userHasSummaryDepartmentAccess(object $user, array $summary): bool
|
||||
{
|
||||
$lane_department = $this->summaryDepartmentId($summary);
|
||||
if ($lane_department <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authorized_department_ids = array_values(array_filter(
|
||||
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
||||
static fn(int $department_id): bool => $department_id > 0
|
||||
));
|
||||
|
||||
return in_array($lane_department, $authorized_department_ids, true);
|
||||
}
|
||||
|
||||
private function requireAuthenticatedCustomerNumber(?object $user, string $elevatedPermission): int
|
||||
{
|
||||
global $response;
|
||||
|
||||
$customer_number = (int)$user->customer_number->value();
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if ($customer_number === null && $user !== null && isset($user->customer_number)) {
|
||||
$customer_number = (int)$user->customer_number->value();
|
||||
}
|
||||
$customer_number = (int)$customer_number;
|
||||
if ($customer_number <= 0) {
|
||||
$response->forbidden([$elevatedPermission]);
|
||||
}
|
||||
@@ -561,28 +678,26 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
return $customer_number;
|
||||
}
|
||||
|
||||
private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void
|
||||
private function assertSummaryAccess(
|
||||
?object $user,
|
||||
array $summary,
|
||||
bool $hasGlobalPermission,
|
||||
bool $hasOwnPermission,
|
||||
string $elevatedPermission
|
||||
): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ($hasGlobalPermission && $user !== null && $this->userHasSummaryDepartmentAccess($user, $summary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasOwnPermission && $this->summaryBelongsToCustomer($user, $summary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasGlobalPermission) {
|
||||
$authorized_department_ids = $user->getGroup()->getDepartments();
|
||||
$lane_department = (int)($summary['lane']['department'] ?? 0);
|
||||
if (!in_array($lane_department, $authorized_department_ids, true)) {
|
||||
$this->forbidDepartmentAccess($lane_department);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
||||
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reg = (string)($summary['session']['reg'] ?? '');
|
||||
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
||||
if ($vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value()) {
|
||||
return;
|
||||
$this->forbidDepartmentAccess($this->summaryDepartmentId($summary));
|
||||
}
|
||||
|
||||
$response->forbidden([$elevatedPermission]);
|
||||
|
||||
@@ -526,7 +526,7 @@ class departmentsRoute
|
||||
'results' => $departments_o->sendSlackInternalStatisticNotification(
|
||||
$week_monday,
|
||||
$week_sunday,
|
||||
[1, 2, 3, 4, 5, 6, 7],
|
||||
[],
|
||||
// Default value
|
||||
[
|
||||
25,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\recaptcha;
|
||||
use classes\selfserve;
|
||||
use classes\virkdata;
|
||||
use objects\department_lanes_o;
|
||||
use objects\departments_o;
|
||||
@@ -53,31 +54,57 @@ class guestRoute
|
||||
$this->get('/guest/departments', function () {
|
||||
global $response;
|
||||
$departments = (new departments_o());
|
||||
try {
|
||||
$self_serve_module_enabled = (bool)(new selfserve())->config->enabled->getVariableValue();
|
||||
} catch (\Throwable) {
|
||||
$self_serve_module_enabled = false;
|
||||
}
|
||||
// Check if the department lane status is requested to be included
|
||||
$include_lane_status = false;
|
||||
if (self::isParametersSet(['include_lanes']) && self::getParameter('include_lanes')) {
|
||||
$include_lane_status = true;
|
||||
}
|
||||
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status) {
|
||||
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status, $self_serve_module_enabled) {
|
||||
$additional_data = [];
|
||||
// If including lane status, fetch it
|
||||
if ($include_lane_status) {
|
||||
$department = $departments->select((int)$department_array['id']);
|
||||
// Include the status of self-serve in the department (disabled, when staffed hours)
|
||||
$additional_data['self_serve_enabled'] = (bool)$department->getSelfServeEnabled();
|
||||
$department_self_serve_enabled = (bool)$department->getSelfServeEnabled();
|
||||
$additional_data['self_serve_enabled'] = $department_self_serve_enabled;
|
||||
$additional_data['self_serve_module_enabled'] = $self_serve_module_enabled;
|
||||
$additional_data['lanes'] = array_map(
|
||||
/**
|
||||
* @param department_lanes_o $lane
|
||||
* @return array
|
||||
*/
|
||||
function (department_lanes_o $lane) {
|
||||
function (department_lanes_o $lane) use ($department_self_serve_enabled, $self_serve_module_enabled) {
|
||||
$status = (string)$lane->getLaneStatus()->name;
|
||||
$lane_self_serve_enabled = $lane->isSelfServeEnabled();
|
||||
$self_serve_available = $self_serve_module_enabled
|
||||
&& $department_self_serve_enabled
|
||||
&& $lane_self_serve_enabled
|
||||
&& strtoupper(trim($status)) === 'AVAILABLE';
|
||||
$self_serve_unavailable_reason = null;
|
||||
if (!$self_serve_module_enabled) {
|
||||
$self_serve_unavailable_reason = 'self_serve_module_disabled';
|
||||
} elseif (!$department_self_serve_enabled) {
|
||||
$self_serve_unavailable_reason = 'department_self_serve_disabled';
|
||||
} elseif (!$lane_self_serve_enabled) {
|
||||
$self_serve_unavailable_reason = 'lane_self_serve_disabled';
|
||||
} elseif (strtoupper(trim($status)) !== 'AVAILABLE') {
|
||||
$self_serve_unavailable_reason = 'lane_status_' . strtolower($status);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$lane->id,
|
||||
'name' => (string)$lane->name->value(),
|
||||
'status' => (string)$lane->getLaneStatus()->name,
|
||||
'status' => $status,
|
||||
'products' => $lane->getSelfServeLaneProducts(),
|
||||
'selfserve_enabled' => $lane->isSelfServeEnabled(),
|
||||
'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()),
|
||||
'selfserve_enabled' => $lane_self_serve_enabled,
|
||||
'selfserve_available' => $self_serve_available,
|
||||
'selfserve_unavailable_reason' => $self_serve_unavailable_reason,
|
||||
'machine_available' => $self_serve_available && !empty($lane->relay_machine_id->value()),
|
||||
'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null,
|
||||
];
|
||||
}, $department->getLanes());
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\limited_backoffice_exception;
|
||||
use classes\limited_backoffice_service;
|
||||
use traits\route_t;
|
||||
|
||||
class limitedBackofficeRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/limited-backoffice/departments', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
return $service->departmentsForUser($user);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$departmentId = $this->routePositiveInt('departmentId');
|
||||
return $service->getDepartmentPrices($user, $departmentId);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_PRICES);
|
||||
$departmentId = $this->routePositiveInt('departmentId');
|
||||
return $service->updateDepartmentPrices($user, $departmentId, $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/roles', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->rolePresets();
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/employees', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
$includeInactive = strtolower((string)($this->fromQuery('include_inactive') ?? 'false')) === 'true';
|
||||
return $service->employeesForUser($user, $includeInactive);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->post('/limited-backoffice/employees', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->createEmployee($user, $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->updateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->delete('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->deactivateEmployee($user, $this->routePositiveInt('employeeId'));
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
}
|
||||
|
||||
private function withLimitedBackoffice(callable $callback): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$response->success($callback(new limited_backoffice_service(), $user));
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$response->error($exception->payload(), $exception->statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestPayload(): array
|
||||
{
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
private function routePositiveInt(string $name): int
|
||||
{
|
||||
$value = $this->fromRoute($name);
|
||||
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
|
||||
throw new limited_backoffice_exception('Invalid route parameter.', 400);
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use classes\n8n;
|
||||
use classes\recaptcha;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\slack;
|
||||
use classes\stripe;
|
||||
use classes\weatherapi;
|
||||
use classes\workfeed;
|
||||
@@ -180,6 +181,158 @@ class moduleConfigRoute
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack config > GET */
|
||||
$this->get('/slack/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully fetched Slack config');
|
||||
$response->success(
|
||||
(new slack())->getConfig()->getConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Get Slack config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack config > POST */
|
||||
$this->post('/slack/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully updated Slack config');
|
||||
$response->success(
|
||||
(new slack())->getConfig()->postConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Update Slack config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack config > TEST */
|
||||
$this->post('/slack/config/test', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$result = (new slack())->test_customer_registration_webhook();
|
||||
if (($result['configured'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration webhook URL is not configured');
|
||||
$response->error($result['message'] ?? 'Slack customer registration webhook URL is not configured.', 400);
|
||||
}
|
||||
|
||||
if (($result['sent'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration test webhook failed');
|
||||
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
||||
}
|
||||
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG_TEST', 'Successfully tested Slack customer registration webhook');
|
||||
$response->success($result);
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Test Slack config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack internal department goal progress config > TEST */
|
||||
$this->post('/slack/config/internal-department-goal-progress/test', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$result = (new slack())->test_internal_department_goal_progress_webhook();
|
||||
if (($result['configured'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress webhook URL is not configured');
|
||||
$response->error($result['message'] ?? 'Slack internal department goal progress webhook URL is not configured.', 400);
|
||||
}
|
||||
|
||||
if (($result['sent'] ?? false) !== true) {
|
||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress test webhook failed');
|
||||
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
||||
}
|
||||
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Successfully tested Slack internal department goal progress webhook');
|
||||
$response->success($result);
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Test Slack internal department goal progress config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack internal department goal progress config > GET */
|
||||
$this->get('/slack/config/internal-department-goal-progress', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully fetched Slack internal department goal progress config');
|
||||
$response->success(
|
||||
(new slack())->get_internal_department_goal_progress_config()
|
||||
);
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Get Slack internal department goal progress config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Slack internal department goal progress config > POST */
|
||||
$this->post('/slack/config/internal-department-goal-progress', function () {
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$webhook_url = $response->getRequestParameter('internal_department_goal_progress_webhook_url')
|
||||
?? $response->getRequestParameter('webhook_url')
|
||||
?? '';
|
||||
$department_ids = $response->getRequestParameter('internal_department_ids')
|
||||
?? $response->getRequestParameter('department_ids')
|
||||
?? [];
|
||||
|
||||
if (!is_array($department_ids)) {
|
||||
$department_ids = array_filter(array_map('trim', explode(',', (string)$department_ids)));
|
||||
}
|
||||
|
||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully updated Slack internal department goal progress config');
|
||||
$response->success(
|
||||
(new slack())->set_internal_department_goal_progress_config(
|
||||
(string)$webhook_url,
|
||||
$department_ids
|
||||
)
|
||||
);
|
||||
},
|
||||
[
|
||||
'slack_config' => 'Update Slack internal department goal progress config'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/backups/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('backups_config');
|
||||
|
||||
@@ -2,21 +2,35 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\image_processor;
|
||||
use classes\licenseplaterecognizer;
|
||||
use classes\openai;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\upload_store;
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleScannerRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const LPR_IMAGE_UPLOAD_FIELD = 'image';
|
||||
private const LPR_IMAGE_UPLOAD_MAX_BYTES = 4194304;
|
||||
private const LPR_CLIENT_CAPTURE_MS_FIELD = 'client_capture_ms';
|
||||
private const LPR_CLIENT_CAPTURE_MAX_MS = 10000;
|
||||
private const LPR_CLIENT_DRAW_MS_FIELD = 'client_draw_ms';
|
||||
private const LPR_CLIENT_ENCODE_MS_FIELD = 'client_encode_ms';
|
||||
private const LPR_CLIENT_FRAME_WIDTH_FIELD = 'client_frame_width';
|
||||
private const LPR_CLIENT_FRAME_HEIGHT_FIELD = 'client_frame_height';
|
||||
private const LPR_CLIENT_FRAME_BYTES_FIELD = 'client_frame_bytes';
|
||||
private const LPR_CLIENT_FRAME_MAX_DIMENSION = 4096;
|
||||
private const LPR_CLIENT_PREFLIGHT_MS_FIELD = 'client_preflight_ms';
|
||||
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = 'client_visual_fingerprint_ms';
|
||||
private const LPR_CLIENT_CAPTURE_MS_HEADER = 'HTTP_X_LPR_CLIENT_CAPTURE_MS';
|
||||
private const LPR_CLIENT_DRAW_MS_HEADER = 'HTTP_X_LPR_CLIENT_DRAW_MS';
|
||||
private const LPR_CLIENT_ENCODE_MS_HEADER = 'HTTP_X_LPR_CLIENT_ENCODE_MS';
|
||||
private const LPR_CLIENT_FRAME_WIDTH_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_WIDTH';
|
||||
private const LPR_CLIENT_FRAME_HEIGHT_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_HEIGHT';
|
||||
private const LPR_CLIENT_FRAME_BYTES_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_BYTES';
|
||||
private const LPR_CLIENT_PREFLIGHT_MS_HEADER = 'HTTP_X_LPR_CLIENT_PREFLIGHT_MS';
|
||||
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER = 'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
@@ -24,27 +38,43 @@ class moduleScannerRoute
|
||||
/** Modules > Scanner > License Plate Recognition > POST */
|
||||
$this->post('/modules/scanner/lpr', function () {
|
||||
global $response;
|
||||
self::requireParameters(['base64_image']);
|
||||
$base64_image = self::getParameter('base64_image');
|
||||
$route_started_at = microtime(true);
|
||||
$image_upload = self::getLPRImageUpload();
|
||||
$raw_image_upload = $image_upload === null ? self::getLPRRawImageUpload() : null;
|
||||
$client_timings = self::getLPRClientTimings();
|
||||
$base64_image = null;
|
||||
//self::requirePermission('modules_scanner_lpr');
|
||||
if (empty($base64_image)) {
|
||||
$response->error('Base64 image is required.');}
|
||||
$uploads = new upload_store();
|
||||
$object_name = $uploads->storeTempImageFromBase64(
|
||||
$base64_image
|
||||
);
|
||||
//echo $object_name;
|
||||
//echo "License Plate Recognition result:\n";
|
||||
// Uncomment the line below to use the actual license plate recognizer.
|
||||
$lpr_result = (new licenseplaterecognizer())->licenseplaterecognizer($base64_image);
|
||||
if ($image_upload === null && $raw_image_upload === null) {
|
||||
$base64_image = self::getParameter('base64_image');
|
||||
if (!is_string($base64_image) || trim($base64_image) === '') {
|
||||
$response->error('Image is required.');
|
||||
}
|
||||
}
|
||||
$recognizer = new licenseplaterecognizer(false);
|
||||
try {
|
||||
if ($image_upload !== null) {
|
||||
$lpr_result = $recognizer->licenseplaterecognizerUploadFile($image_upload['path'], $image_upload['mime_type']);
|
||||
} elseif ($raw_image_upload !== null) {
|
||||
$lpr_result = $recognizer->licenseplaterecognizerUploadUncached($raw_image_upload['data'], $raw_image_upload['mime_type']);
|
||||
} else {
|
||||
$lpr_result = $recognizer->licenseplaterecognizer((string)$base64_image);
|
||||
}
|
||||
} finally {
|
||||
self::sendLPRServerTiming(array_merge($client_timings, $recognizer->getLastTimings()), $route_started_at);
|
||||
}
|
||||
if ($lpr_result['success']) {
|
||||
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
||||
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
||||
// Make sure the scanned plate is more than 3 characters long.
|
||||
$scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3;
|
||||
// If the confidence is below 90%, consider it a failure.
|
||||
// If the confidence is below 90%, treat it as a recoverable scanner miss.
|
||||
if (isset($lpr_result['confidence']) && $lpr_result['confidence'] < 0.9 && !$scannedPlateIsTooShort) {
|
||||
throw new Exception('License plate recognition confidence too low. Score: ' . $lpr_result['confidence'] . ' Plate: ' . $lpr_result['license_plate_number'] . ' Raw: ' . json_encode($lpr_result['raw_response']));
|
||||
$response->response(false, [
|
||||
'message' => 'License plate recognition confidence too low.',
|
||||
'reason' => 'low_confidence_license_plate',
|
||||
'confidence' => $lpr_result['confidence'],
|
||||
'license_plate_number' => $lpr_result['license_plate_number'],
|
||||
], 200);
|
||||
}
|
||||
// Success
|
||||
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
||||
@@ -54,25 +84,296 @@ class moduleScannerRoute
|
||||
'reason' => 'no_license_plate_detected',
|
||||
], 200);
|
||||
}
|
||||
exit;
|
||||
// For future use with OpenAI.
|
||||
$openai = new openai();
|
||||
$registration_numbers_debug = [
|
||||
'EC21233',
|
||||
'EC21234',
|
||||
'EC21235',
|
||||
];
|
||||
$random_index = rand(0, count($registration_numbers_debug) - 1);
|
||||
$object_name = $registration_numbers_debug[$random_index];
|
||||
// Attempt to recognize the license plate number from the image.
|
||||
|
||||
//Debug: TODO: Remove this.
|
||||
$response->success(['success' => true, 'license_plate_number' => $object_name]);
|
||||
$response->success($openai->lpr($object_name));
|
||||
},
|
||||
[
|
||||
'modules_scanner_lpr' => 'License Plate Recognition',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private static function getLPRImageUpload(): ?array
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!isset($_FILES[self::LPR_IMAGE_UPLOAD_FIELD]) || !is_array($_FILES[self::LPR_IMAGE_UPLOAD_FIELD])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = $_FILES[self::LPR_IMAGE_UPLOAD_FIELD];
|
||||
if (is_array($file['error'] ?? null)) {
|
||||
$response->error('Only one image can be uploaded.', 400);
|
||||
}
|
||||
|
||||
$upload_error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($upload_error === UPLOAD_ERR_NO_FILE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($upload_error !== UPLOAD_ERR_OK) {
|
||||
$response->error('Image upload failed.', 400);
|
||||
}
|
||||
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
if ($size <= 0) {
|
||||
$response->error('Image upload is empty.', 400);
|
||||
}
|
||||
|
||||
if ($size > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
||||
$response->error('Image upload is too large.', 413);
|
||||
}
|
||||
|
||||
$tmp_name = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmp_name === '' || !is_uploaded_file($tmp_name)) {
|
||||
$response->error('Image upload is invalid.', 400);
|
||||
}
|
||||
|
||||
if (!is_readable($tmp_name)) {
|
||||
$response->error('Image upload could not be read.', 400);
|
||||
}
|
||||
|
||||
$mime_type = self::detectLPRImageMimeType($file, $tmp_name);
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$response->error('Image upload must be an image.', 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $tmp_name,
|
||||
'mime_type' => $mime_type,
|
||||
];
|
||||
}
|
||||
|
||||
private static function getLPRRawImageUpload(): ?array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$mime_type = self::getRequestContentType();
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content_length = isset($_SERVER['CONTENT_LENGTH']) && is_numeric($_SERVER['CONTENT_LENGTH'])
|
||||
? (int)$_SERVER['CONTENT_LENGTH']
|
||||
: null;
|
||||
if ($content_length !== null && $content_length > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
||||
$response->error('Image upload is too large.', 413);
|
||||
}
|
||||
|
||||
$image_data = file_get_contents('php://input');
|
||||
if (!is_string($image_data) || $image_data === '') {
|
||||
$response->error('Image upload is empty.', 400);
|
||||
}
|
||||
|
||||
if (strlen($image_data) > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
||||
$response->error('Image upload is too large.', 413);
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $image_data,
|
||||
'mime_type' => $mime_type,
|
||||
];
|
||||
}
|
||||
|
||||
private static function getRequestContentType(): string
|
||||
{
|
||||
$content_type = (string)($_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '');
|
||||
$content_type = strtolower(trim(explode(';', $content_type, 2)[0] ?? ''));
|
||||
|
||||
return $content_type;
|
||||
}
|
||||
|
||||
private static function detectLPRImageMimeType(array $file, string $tmp_name): string
|
||||
{
|
||||
$mime_type = trim((string)($file['type'] ?? ''));
|
||||
if ($mime_type !== '') {
|
||||
return $mime_type;
|
||||
}
|
||||
|
||||
if (class_exists(\finfo::class)) {
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$detected = $finfo->file($tmp_name);
|
||||
if (is_string($detected) && $detected !== '') {
|
||||
return $detected;
|
||||
}
|
||||
}
|
||||
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
private static function getLPRClientTimings(): array
|
||||
{
|
||||
$timings = [];
|
||||
$client_capture_ms = self::getNumericClientField(
|
||||
self::LPR_CLIENT_CAPTURE_MS_FIELD,
|
||||
self::LPR_CLIENT_CAPTURE_MS_HEADER,
|
||||
0,
|
||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
||||
);
|
||||
if ($client_capture_ms !== null) {
|
||||
$timings['client_capture'] = $client_capture_ms;
|
||||
}
|
||||
|
||||
$client_preflight_ms = self::getNumericClientField(
|
||||
self::LPR_CLIENT_PREFLIGHT_MS_FIELD,
|
||||
self::LPR_CLIENT_PREFLIGHT_MS_HEADER,
|
||||
0,
|
||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
||||
);
|
||||
if ($client_preflight_ms !== null) {
|
||||
$timings['client_preflight'] = $client_preflight_ms;
|
||||
}
|
||||
|
||||
$client_draw_ms = self::getNumericClientField(
|
||||
self::LPR_CLIENT_DRAW_MS_FIELD,
|
||||
self::LPR_CLIENT_DRAW_MS_HEADER,
|
||||
0,
|
||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
||||
);
|
||||
if ($client_draw_ms !== null) {
|
||||
$timings['client_draw'] = $client_draw_ms;
|
||||
}
|
||||
|
||||
$client_encode_ms = self::getNumericClientField(
|
||||
self::LPR_CLIENT_ENCODE_MS_FIELD,
|
||||
self::LPR_CLIENT_ENCODE_MS_HEADER,
|
||||
0,
|
||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
||||
);
|
||||
if ($client_encode_ms !== null) {
|
||||
$timings['client_encode'] = $client_encode_ms;
|
||||
}
|
||||
|
||||
$client_visual_fingerprint_ms = self::getNumericClientField(
|
||||
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
|
||||
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER,
|
||||
0,
|
||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
||||
);
|
||||
if ($client_visual_fingerprint_ms !== null) {
|
||||
$timings['client_visual_fingerprint'] = $client_visual_fingerprint_ms;
|
||||
}
|
||||
|
||||
$client_frame_width = self::getNumericClientField(
|
||||
self::LPR_CLIENT_FRAME_WIDTH_FIELD,
|
||||
self::LPR_CLIENT_FRAME_WIDTH_HEADER,
|
||||
1,
|
||||
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
||||
);
|
||||
if ($client_frame_width !== null) {
|
||||
$timings['client_frame_width'] = $client_frame_width;
|
||||
}
|
||||
|
||||
$client_frame_height = self::getNumericClientField(
|
||||
self::LPR_CLIENT_FRAME_HEIGHT_FIELD,
|
||||
self::LPR_CLIENT_FRAME_HEIGHT_HEADER,
|
||||
1,
|
||||
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
||||
);
|
||||
if ($client_frame_height !== null) {
|
||||
$timings['client_frame_height'] = $client_frame_height;
|
||||
}
|
||||
|
||||
$client_frame_bytes = self::getNumericClientField(
|
||||
self::LPR_CLIENT_FRAME_BYTES_FIELD,
|
||||
self::LPR_CLIENT_FRAME_BYTES_HEADER,
|
||||
1,
|
||||
self::LPR_IMAGE_UPLOAD_MAX_BYTES
|
||||
);
|
||||
if ($client_frame_bytes !== null) {
|
||||
$timings['client_frame_bytes'] = $client_frame_bytes;
|
||||
}
|
||||
|
||||
return $timings;
|
||||
}
|
||||
|
||||
private static function getNumericClientField(string $field, string $server_header, float $min, float $max): ?float
|
||||
{
|
||||
$value = $_GET[$field] ?? null;
|
||||
if ($value === null) {
|
||||
$value = $_POST[$field] ?? null;
|
||||
}
|
||||
if ($value === null) {
|
||||
$value = $_SERVER[$server_header] ?? null;
|
||||
}
|
||||
if (is_array($value) || !is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (float)$value;
|
||||
if ($value < $min || $value > $max) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function sendLPRServerTiming(array $timings, float $route_started_at): void
|
||||
{
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$timings['route_total'] = max(0, (microtime(true) - $route_started_at) * 1000);
|
||||
$timings['local'] = self::getLPRLocalDuration($timings);
|
||||
if (isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT'])) {
|
||||
$timings['request_total'] = max(0, (microtime(true) - (float)$_SERVER['REQUEST_TIME_FLOAT']) * 1000);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'client_capture',
|
||||
'client_preflight',
|
||||
'client_visual_fingerprint',
|
||||
'client_draw',
|
||||
'client_encode',
|
||||
'client_frame_width',
|
||||
'client_frame_height',
|
||||
'client_frame_bytes',
|
||||
'config',
|
||||
'cache',
|
||||
'cache_hit',
|
||||
'cache_miss',
|
||||
'local',
|
||||
'payload',
|
||||
'upstream_dns',
|
||||
'upstream_connect',
|
||||
'upstream_tls',
|
||||
'upstream_pretransfer',
|
||||
'upstream_ttfb',
|
||||
'upstream_total',
|
||||
'upstream_processing',
|
||||
'upstream',
|
||||
'parse',
|
||||
'total',
|
||||
] as $name) {
|
||||
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
||||
}
|
||||
foreach (['route_total', 'request_total'] as $name) {
|
||||
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
header('Server-Timing: ' . implode(', ', $parts));
|
||||
}
|
||||
}
|
||||
|
||||
private static function getLPRLocalDuration(array $timings): float
|
||||
{
|
||||
$upstream = null;
|
||||
foreach (['upstream', 'upstream_total'] as $name) {
|
||||
if (isset($timings[$name]) && is_numeric($timings[$name])) {
|
||||
$upstream = max(0, (float)$timings[$name]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, (float)$timings['route_total'] - ($upstream ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,31 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\email;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\selfserve;
|
||||
use classes\stripe;
|
||||
use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use modules\selfserve\helpers\selfserve_lane_command;
|
||||
use modules\selfserve\helpers\selfserve_lane_port;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_lane_state;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\selfserve\helpers\selfserve_wash_session_status;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
use objects\department_lanes_o;
|
||||
use objects\departments_o;
|
||||
use objects\edge_gateway_relay_bindings_o;
|
||||
use objects\logs_o;
|
||||
use objects\orders_o;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\selfserve_wash_session_tasks_o;
|
||||
use objects\stripe_module_customers_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
@@ -567,6 +573,30 @@ class moduleSelfServeRoute
|
||||
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
|
||||
]);
|
||||
$lane->execute($command, $args);
|
||||
if ($command === selfserve_lane_command::START) {
|
||||
try {
|
||||
$license_plate = selfserve::standardize_registration(
|
||||
(string)($args->license_plate ?: $lane->getLicensePlate())
|
||||
);
|
||||
if ($license_plate !== '') {
|
||||
(new selfserve_wash_flow())->synchronizeSession(
|
||||
$lane_id,
|
||||
$license_plate,
|
||||
$customer_number > 0 ? $customer_number : null,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log(
|
||||
'Failed to persist self-serve START session for lane '
|
||||
. $lane_id
|
||||
. ': '
|
||||
. $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
$response->success([
|
||||
'id' => $lane->id,
|
||||
'status' => $lane->getLaneStatus()->name,
|
||||
@@ -630,23 +660,70 @@ class moduleSelfServeRoute
|
||||
// Build allowed services from provided tasks
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||
$this->requireSelfServeLaneAccess(
|
||||
$lane,
|
||||
$customer_number === null ? 0 : (int)$customer_number,
|
||||
['modules_selfserve_lane_services_set_allowed'],
|
||||
false
|
||||
);
|
||||
$session_task_services = [];
|
||||
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
|
||||
$lane_id,
|
||||
$customer_number === null || (int)$customer_number <= 0 ? null : (int)$customer_number
|
||||
);
|
||||
if (!$latest_session->exists() && $customer_number !== null && (int)$customer_number > 0) {
|
||||
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane($lane_id);
|
||||
}
|
||||
if ($latest_session->exists()) {
|
||||
foreach ((new selfserve_wash_session_tasks_o())->listBySession((int)$latest_session->id) as $session_task) {
|
||||
$task_id = (int)($session_task['task_id'] ?? 0);
|
||||
if ($task_id > 0) {
|
||||
$services = $session_task['services'] ?? [];
|
||||
if (is_string($services)) {
|
||||
$decoded_services = json_decode($services, true);
|
||||
$services = json_last_error() === JSON_ERROR_NONE && is_array($decoded_services)
|
||||
? $decoded_services
|
||||
: [];
|
||||
}
|
||||
$session_task_services[$task_id] = is_array($services) ? $services : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowed_services = [];
|
||||
foreach ($task_ids as $tid) {
|
||||
if ($tid <= 0) continue;
|
||||
$t = new \objects\department_selfserve_tasks_o();
|
||||
$t->select($tid);
|
||||
if (!$t->exists()) continue; // ignore unknown ids
|
||||
// Validate the task belongs to the same lane
|
||||
if ((int)$t->lane->value() !== $lane_id) continue;
|
||||
// Merge services (if any)
|
||||
$services = (array)$t->services->value();
|
||||
$published_config_task_services = $this->publishedConfigTaskServicesForLane($lane, $task_ids);
|
||||
$merge_services = static function (array $services) use (&$allowed_services): void {
|
||||
foreach ($services as $srv) {
|
||||
$name = strtoupper((string)$srv);
|
||||
if (!in_array($name, $allowed_services, true)) {
|
||||
$allowed_services[] = $name;
|
||||
}
|
||||
}
|
||||
};
|
||||
foreach ($task_ids as $tid) {
|
||||
if ($tid <= 0) continue;
|
||||
if (array_key_exists($tid, $session_task_services)) {
|
||||
$merge_services($session_task_services[$tid]);
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($tid, $published_config_task_services)) {
|
||||
$merge_services($published_config_task_services[$tid]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$t = new \objects\department_selfserve_tasks_o();
|
||||
$t->select($tid);
|
||||
if (!$t->exists()) continue; // ignore unknown ids
|
||||
// Validate the task belongs to the same lane
|
||||
if ((int)$t->lane->value() !== $lane_id) continue;
|
||||
// Merge services (if any)
|
||||
$merge_services((array)$t->services->value());
|
||||
}
|
||||
if (!$this->isMachineWashEnabled()) {
|
||||
$allowed_services = array_values(array_filter(
|
||||
$allowed_services,
|
||||
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
|
||||
));
|
||||
}
|
||||
// Persist on lane cache (overwrites previous allowed services)
|
||||
try {
|
||||
@@ -688,15 +765,23 @@ class moduleSelfServeRoute
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->open($gate, $toggle_after);
|
||||
$queued_gate_command = null;
|
||||
if ($this->requestedShellyTransportOverride() === 'local') {
|
||||
$queued_gate_command = $this->queueLocalLaneGateOpen($lane, $gate, $toggle_after);
|
||||
} else {
|
||||
$lane->open($gate, $toggle_after);
|
||||
}
|
||||
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
'gate' => $gate->name,
|
||||
'opened' => true,
|
||||
'queued' => $queued_gate_command !== null,
|
||||
'batch' => $queued_gate_command,
|
||||
'toggle_after' => $toggle_after,
|
||||
'state' => $lane->getLaneState()->name,
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
], $queued_gate_command !== null ? 202 : 200);
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
|
||||
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
|
||||
@@ -705,6 +790,83 @@ class moduleSelfServeRoute
|
||||
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
|
||||
]);
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch status */
|
||||
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
self::requireParameters(['lane_id']);
|
||||
$lane_id = (int)$this->getParameter('lane_id');
|
||||
self::requireType($lane_id, self::type_int());
|
||||
self::requireMinValue($lane_id, 1);
|
||||
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$targets = self::isParametersSet(['targets']) ? (array)$this->getParameter('targets') : [
|
||||
'MACHINE',
|
||||
'PROGRAM_PICKER',
|
||||
'CLEANER',
|
||||
'ENTRANCE',
|
||||
'EXIT',
|
||||
];
|
||||
$requests = $this->buildLaneHardwareBatchRequests($lane, $targets, false);
|
||||
$user = (new authentication())->get_user();
|
||||
$result = (new edge_gateway_manager())->queueRelayStatusBatch(
|
||||
$this->departmentIdForLane($lane),
|
||||
$requests,
|
||||
$user instanceof users_o ? (int)$user->id : null,
|
||||
$this->requestedShellyTransportOverride() === 'local'
|
||||
);
|
||||
$response->success($result, 202);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to queue lane hardware status batch: ' . $e->getMessage(), 400);
|
||||
}
|
||||
}, [
|
||||
'modules_selfserve_lane_relay_machine_status_view' => 'Queue self-serve lane hardware status batch'
|
||||
]);
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
|
||||
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
self::requireParameters(['lane_id', 'commands']);
|
||||
$lane_id = (int)$this->getParameter('lane_id');
|
||||
self::requireType($lane_id, self::type_int());
|
||||
self::requireMinValue($lane_id, 1);
|
||||
|
||||
$commands = (array)$this->getParameter('commands');
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$requests = $this->buildLaneHardwareBatchRequests($lane, $commands, true);
|
||||
$user = (new authentication())->get_user();
|
||||
$result = (new edge_gateway_manager())->queueRelaySwitchBatch(
|
||||
$this->departmentIdForLane($lane),
|
||||
$requests,
|
||||
$user instanceof users_o ? (int)$user->id : null,
|
||||
$this->requestedShellyTransportOverride() === 'local'
|
||||
);
|
||||
$response->success($result, 202);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to queue lane hardware command batch: ' . $e->getMessage(), 400);
|
||||
}
|
||||
}, [
|
||||
'modules_selfserve_lane_relay_machine_status_set' => 'Queue self-serve lane hardware command batch'
|
||||
]);
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch poll */
|
||||
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
|
||||
try {
|
||||
$response->success((new edge_gateway_manager())->relayBatchStatus((string)$this->fromRoute('batch_id')));
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to get lane hardware batch status: ' . $e->getMessage(), 400);
|
||||
}
|
||||
}, [
|
||||
'modules_selfserve_lane_relay_machine_status_view' => 'Poll self-serve lane hardware batch'
|
||||
]);
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
|
||||
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
|
||||
global $response;
|
||||
@@ -1263,8 +1425,7 @@ class moduleSelfServeRoute
|
||||
{
|
||||
global $response;
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
if (!$this->hasAuthenticatedUserOrSubuser()) {
|
||||
$response->error('Authentication failed. Invalid or missing token.', 401);
|
||||
}
|
||||
|
||||
@@ -1277,7 +1438,7 @@ class moduleSelfServeRoute
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) {
|
||||
if (self::hasPermission($this->customerSelfServePermission())) {
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if ($customer_number !== null && $customer_number > 0) {
|
||||
return (int)$customer_number;
|
||||
@@ -1286,7 +1447,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$this->emitForbidden([
|
||||
'modules_selfserve_lane_wash_in_progress_view',
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
$this->customerSelfServePermission(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
@@ -1345,13 +1506,12 @@ class moduleSelfServeRoute
|
||||
{
|
||||
global $response;
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
if (!$this->hasAuthenticatedUserOrSubuser()) {
|
||||
$response->error('Authentication failed. Invalid or missing token.', 401);
|
||||
}
|
||||
|
||||
if (!self::hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||
$this->emitForbidden([self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
if (!self::hasPermission($this->customerSelfServePermission())) {
|
||||
$this->emitForbidden([$this->customerSelfServePermission()]);
|
||||
}
|
||||
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
@@ -1362,6 +1522,17 @@ class moduleSelfServeRoute
|
||||
return (int)$customer_number;
|
||||
}
|
||||
|
||||
private function customerSelfServePermission(): \classes\permission_node
|
||||
{
|
||||
return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
|
||||
}
|
||||
|
||||
private function hasAuthenticatedUserOrSubuser(): bool
|
||||
{
|
||||
$auth = new authentication();
|
||||
return $auth->get_user() !== false || $auth->get_subuser() !== false;
|
||||
}
|
||||
|
||||
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
@@ -1557,6 +1728,305 @@ class moduleSelfServeRoute
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function queueLocalLaneGateOpen(selfserve_lane $lane, selfserve_lane_port $gate, int $toggleAfter): array
|
||||
{
|
||||
$requests = $this->buildLaneHardwareBatchRequests($lane, [[
|
||||
'target' => $gate->name,
|
||||
'action' => 'OPEN',
|
||||
'toggle_after' => $toggleAfter,
|
||||
]], true);
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
return (new edge_gateway_manager())->queueRelaySwitchBatch(
|
||||
$this->departmentIdForLane($lane),
|
||||
$requests,
|
||||
$user instanceof users_o ? (int)$user->id : null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $items
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function buildLaneHardwareBatchRequests(selfserve_lane $lane, array $items, bool $mutation): array
|
||||
{
|
||||
if (empty($lane->department_lane)) {
|
||||
throw new \Exception("Department lane object not found for lane ID {$lane->id}");
|
||||
}
|
||||
|
||||
$requests = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
$command = is_array($item) ? $item : ['target' => $item];
|
||||
$target = strtoupper(trim((string)($command['target'] ?? $command['relay'] ?? '')));
|
||||
if ($target === '') {
|
||||
throw new \Exception('Lane hardware batch commands require a target');
|
||||
}
|
||||
if (isset($seen[$target])) {
|
||||
throw new \Exception('Duplicate lane hardware target: ' . $target);
|
||||
}
|
||||
$seen[$target] = true;
|
||||
|
||||
$relayId = $this->relayIdForLaneHardwareTarget($lane, $target);
|
||||
if ($relayId === '') {
|
||||
throw new \Exception('No relay is configured for lane hardware target ' . $target);
|
||||
}
|
||||
|
||||
if ($mutation) {
|
||||
$this->requireLaneHardwareMutationPermission($target);
|
||||
} else {
|
||||
$this->requireLaneHardwareStatusPermission($target);
|
||||
}
|
||||
|
||||
$request = [
|
||||
'target' => $target,
|
||||
'relay_id' => $relayId,
|
||||
'relayId' => $relayId,
|
||||
'lane_id' => (int)$lane->id,
|
||||
'laneId' => (int)$lane->id,
|
||||
];
|
||||
|
||||
if ($mutation) {
|
||||
if (in_array($target, ['ENTRANCE', 'EXIT'], true)) {
|
||||
$action = strtoupper(trim((string)($command['action'] ?? 'OPEN')));
|
||||
if ($action !== 'OPEN') {
|
||||
throw new \Exception($target . ' only supports action=OPEN');
|
||||
}
|
||||
$request['on'] = true;
|
||||
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
|
||||
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'] ?? 1,
|
||||
self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS
|
||||
);
|
||||
} else {
|
||||
if (!array_key_exists('on', $command)) {
|
||||
throw new \Exception($target . ' relay commands require an on boolean');
|
||||
}
|
||||
$on = is_bool($command['on'])
|
||||
? $command['on']
|
||||
: filter_var($command['on'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($on === null) {
|
||||
throw new \Exception('Invalid on value for ' . $target . '. Expected boolean true/false.');
|
||||
}
|
||||
$request['on'] = (bool)$on;
|
||||
if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) {
|
||||
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
|
||||
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'],
|
||||
86400
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$requests[] = $request;
|
||||
}
|
||||
|
||||
if ($requests === []) {
|
||||
throw new \Exception('At least one lane hardware target is required');
|
||||
}
|
||||
if (count($requests) > 5) {
|
||||
throw new \Exception('Lane hardware batches are limited to five targets');
|
||||
}
|
||||
|
||||
return $requests;
|
||||
}
|
||||
|
||||
private function relayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
|
||||
{
|
||||
$relayId = trim((string)match ($target) {
|
||||
'MACHINE' => $lane->department_lane->relay_machine_id->value(),
|
||||
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => $lane->department_lane->relay_machine_program_picker_id->value(),
|
||||
'CLEANER', 'MACHINE_CLEANER' => $lane->department_lane->relay_machine_cleaner_id->value(),
|
||||
'ENTRANCE' => $lane->department_lane->relay_in_id->value(),
|
||||
'EXIT' => $lane->department_lane->relay_out_id->value(),
|
||||
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||
});
|
||||
|
||||
if ($relayId !== '') {
|
||||
return $relayId;
|
||||
}
|
||||
|
||||
return $this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target);
|
||||
}
|
||||
|
||||
private function edgeGatewayRelayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
|
||||
{
|
||||
$departmentId = $this->departmentIdForLane($lane);
|
||||
if ($departmentId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
|
||||
'department_id' => $departmentId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
$laneScoped = [];
|
||||
$unscoped = [];
|
||||
foreach ($rows as $row) {
|
||||
$binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
|
||||
if (!$binding->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $binding->asArray();
|
||||
if (!$this->edgeGatewayBindingMatchesHardwareTarget($payload, $target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->edgeGatewayBindingMatchesLane($payload, (int)$lane->id, $target)) {
|
||||
$laneScoped[] = (string)$payload['relay_id'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->edgeGatewayBindingHasConsumers($payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unscoped[] = (string)$payload['relay_id'];
|
||||
}
|
||||
|
||||
$candidates = $laneScoped !== [] ? array_values(array_unique($laneScoped)) : array_values(array_unique($unscoped));
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
if (count($candidates) > 1) {
|
||||
throw new \Exception('Multiple edge gateway relay bindings match lane hardware target ' . $target);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $binding
|
||||
*/
|
||||
private function edgeGatewayBindingMatchesHardwareTarget(array $binding, string $target): bool
|
||||
{
|
||||
$accepted = match ($target) {
|
||||
'MACHINE' => ['MACHINE'],
|
||||
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
|
||||
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
|
||||
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
|
||||
'EXIT' => ['EXIT'],
|
||||
default => [],
|
||||
};
|
||||
if ($accepted === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||
$values = [
|
||||
$binding['role'] ?? null,
|
||||
$binding['service'] ?? null,
|
||||
$binding['slot'] ?? null,
|
||||
$metadata['role'] ?? null,
|
||||
$metadata['relay_role'] ?? null,
|
||||
$metadata['service'] ?? null,
|
||||
$metadata['slot'] ?? null,
|
||||
];
|
||||
foreach ((array)($binding['services'] ?? $metadata['services'] ?? []) as $service) {
|
||||
$values[] = $service;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (in_array($this->normalizeLaneHardwareTargetRole($value), $accepted, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $binding
|
||||
*/
|
||||
private function edgeGatewayBindingMatchesLane(array $binding, int $laneId, string $target): bool
|
||||
{
|
||||
$targetRoles = match ($target) {
|
||||
'MACHINE' => ['MACHINE'],
|
||||
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
|
||||
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
|
||||
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
|
||||
'EXIT' => ['EXIT'],
|
||||
default => [],
|
||||
};
|
||||
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
|
||||
if (!is_array($consumers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($consumers as $consumer) {
|
||||
if (!is_array($consumer)) {
|
||||
continue;
|
||||
}
|
||||
if (strtolower(trim((string)($consumer['type'] ?? ''))) !== 'lane') {
|
||||
continue;
|
||||
}
|
||||
if ((int)($consumer['id'] ?? 0) !== $laneId) {
|
||||
continue;
|
||||
}
|
||||
$slot = $this->normalizeLaneHardwareTargetRole($consumer['slot'] ?? '');
|
||||
if ($slot === '' || in_array($slot, $targetRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $binding
|
||||
*/
|
||||
private function edgeGatewayBindingHasConsumers(array $binding): bool
|
||||
{
|
||||
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
|
||||
return is_array($consumers) && $consumers !== [];
|
||||
}
|
||||
|
||||
private function normalizeLaneHardwareTargetRole(mixed $value): string
|
||||
{
|
||||
return strtoupper(str_replace(['-', ' '], '_', trim((string)($value ?? ''))));
|
||||
}
|
||||
|
||||
private function requireLaneHardwareStatusPermission(string $target): void
|
||||
{
|
||||
match ($target) {
|
||||
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_view'),
|
||||
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'),
|
||||
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'),
|
||||
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
|
||||
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||
};
|
||||
}
|
||||
|
||||
private function requireLaneHardwareMutationPermission(string $target): void
|
||||
{
|
||||
match ($target) {
|
||||
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_set'),
|
||||
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'),
|
||||
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'),
|
||||
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
|
||||
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeBatchToggleAfter(mixed $value, int $max): int
|
||||
{
|
||||
$seconds = (int)$value;
|
||||
if ($seconds <= 0) {
|
||||
throw new \Exception('toggle_after must be greater than zero');
|
||||
}
|
||||
|
||||
return min($seconds, $max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
@@ -1603,9 +2073,13 @@ class moduleSelfServeRoute
|
||||
}
|
||||
|
||||
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 ($requires_active_wash && $allow_department_active_wash) {
|
||||
$customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
||||
} else {
|
||||
$customer_allowed = $requires_active_wash
|
||||
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false)
|
||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
@@ -1614,7 +2088,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
|
||||
if ($allow_customer_self_serve) {
|
||||
$missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION;
|
||||
$missing_permissions[] = $this->customerSelfServePermission();
|
||||
}
|
||||
$this->emitForbidden($missing_permissions);
|
||||
}
|
||||
@@ -1658,7 +2132,7 @@ class moduleSelfServeRoute
|
||||
return;
|
||||
}
|
||||
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
|
||||
}
|
||||
|
||||
private function requireSelfServeLaneCommandPermission(
|
||||
@@ -1689,7 +2163,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$this->emitForbidden(
|
||||
$allow_customer_self_serve
|
||||
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
|
||||
? [...$elevated_permissions, $this->customerSelfServePermission()]
|
||||
: $elevated_permissions
|
||||
);
|
||||
}
|
||||
@@ -1720,28 +2194,37 @@ class moduleSelfServeRoute
|
||||
return;
|
||||
}
|
||||
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
|
||||
}
|
||||
|
||||
protected function isSelfServeModuleEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return (bool)(new selfserve())->config->enabled->getVariableValue();
|
||||
return (new selfserve())->config->enabled->isTrue();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function isMachineWashEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return (new selfserve())->config->machine_wash_enabled->isTrue();
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $customer_number > 0
|
||||
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
|
||||
&& $this->hasPermission($this->customerSelfServePermission())
|
||||
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1760,7 +2243,17 @@ class moduleSelfServeRoute
|
||||
|
||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
|
||||
if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||
@@ -1778,7 +2271,7 @@ class moduleSelfServeRoute
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1899,6 +2392,59 @@ class moduleSelfServeRoute
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,int> $task_ids
|
||||
* @return array<int,array<int,string>>
|
||||
*/
|
||||
private function publishedConfigTaskServicesForLane(selfserve_lane $lane, array $task_ids): array
|
||||
{
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
if ($department_id <= 0 || $task_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$published = (new selfserve_config_versioning())->getPublishedConfig($department_id);
|
||||
$config = is_array($published) ? (array)($published['config'] ?? []) : [];
|
||||
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
|
||||
if ($tasks === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$requested_task_ids = array_fill_keys(array_map(static fn($task_id): int => (int)$task_id, $task_ids), true);
|
||||
$services_by_task_id = [];
|
||||
foreach ($tasks as $task) {
|
||||
if (!is_array($task)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task_id = (int)($task['id'] ?? $task['task_id'] ?? 0);
|
||||
if ($task_id <= 0 || !isset($requested_task_ids[$task_id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task_department_id = (int)($task['department'] ?? $task['department_id'] ?? 0);
|
||||
if ($task_department_id !== 0 && $task_department_id !== $department_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task_lane_id = (int)($task['lane'] ?? $task['lane_id'] ?? 0);
|
||||
if ($task_lane_id !== 0 && $task_lane_id !== (int)$lane->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$services = $task['services'] ?? [];
|
||||
if (is_string($services)) {
|
||||
$decoded = json_decode($services, true);
|
||||
$services = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$services_by_task_id[$task_id] = is_array($services) ? $services : [];
|
||||
}
|
||||
|
||||
return $services_by_task_id;
|
||||
}
|
||||
|
||||
private function requestedShellyTransportOverride(): ?string
|
||||
{
|
||||
$transport = null;
|
||||
|
||||
@@ -392,6 +392,37 @@ class orderBookingRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/order-bookings/completion-confirmation/resend', function () {
|
||||
global $response;
|
||||
|
||||
$object = self::getTargetObject();
|
||||
if (!$object || !$object->exists()) {
|
||||
$response->error('Order booking does not exist.', 400);
|
||||
}
|
||||
|
||||
self::requirePermission('complete_bookings');
|
||||
self::requireDepartmentAccess((int)$object->department->value());
|
||||
|
||||
if (!$object->hasTransaction()) {
|
||||
$response->error('Order booking has not been completed yet.', 409);
|
||||
}
|
||||
|
||||
if (!$object->getOrder()->hasWashCertificateAttached()) {
|
||||
$response->error('Order booking completion confirmation is not available yet.', 409);
|
||||
}
|
||||
|
||||
(new email())->sendWashCertificateEmailToCustomer($object);
|
||||
|
||||
$response->success([
|
||||
'message' => 'Completion confirmation resent successfully.',
|
||||
'booking' => $object->asArray(),
|
||||
]);
|
||||
},
|
||||
[
|
||||
'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.'
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/order-bookings/complete', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user