Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4dd49208a |
@@ -11,8 +11,6 @@ services:
|
|||||||
|
|
||||||
edge-broker:
|
edge-broker:
|
||||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||||
@@ -82,9 +80,3 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ci_php_app:
|
ci_php_app:
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
qodana:
|
qodana:
|
||||||
# CI runs on the repository's self-hosted runner pool.
|
# CI runs on the repository's self-hosted runner pool.
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
assign-task:
|
assign-task:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+42
-122
@@ -7,7 +7,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
php:
|
php:
|
||||||
name: PHP ${{ matrix.suite }} (required)
|
name: PHP ${{ matrix.suite }} (required)
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -19,20 +19,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Node.js
|
||||||
if: ${{ matrix.suite == 'unit' }}
|
if: ${{ matrix.suite == 'unit' }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -58,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
edge-agent:
|
edge-agent:
|
||||||
name: Edge Agent (required)
|
name: Edge Agent (required)
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -68,6 +54,8 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: services/edge-agent/package-lock.json
|
||||||
|
|
||||||
- name: Install native build tools
|
- name: Install native build tools
|
||||||
run: |
|
run: |
|
||||||
@@ -103,26 +91,12 @@ jobs:
|
|||||||
|
|
||||||
edge-broker:
|
edge-broker:
|
||||||
name: Edge Broker (required)
|
name: Edge Broker (required)
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Materialize CI compose env files
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -138,6 +112,8 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: services/edge-broker/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: services/edge-broker
|
working-directory: services/edge-broker
|
||||||
@@ -149,83 +125,26 @@ jobs:
|
|||||||
|
|
||||||
edge-gateway-backend:
|
edge-gateway-backend:
|
||||||
name: Edge Gateway Backend (required)
|
name: Edge Gateway Backend (required)
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
COMPOSE_PROFILES: dev
|
|
||||||
TRAEFIK_WEB_PORT: "18080"
|
TRAEFIK_WEB_PORT: "18080"
|
||||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||||
TRAEFIK_METRICS_PORT: "19100"
|
TRAEFIK_METRICS_PORT: "19100"
|
||||||
EDGE_BROKER_CI_PORT: "14300"
|
|
||||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Materialize CI compose env files
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cp .github/ci.env .env
|
cp .github/ci.env .env
|
||||||
cp .github/ci.env.staging .env.staging
|
cp .github/ci.env.staging .env.staging
|
||||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -233,7 +152,7 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Boot local stack
|
- name: Boot local stack
|
||||||
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||||
|
|
||||||
- name: Sync PHP app checkout
|
- name: Sync PHP app checkout
|
||||||
run: >
|
run: >
|
||||||
@@ -242,33 +161,10 @@ jobs:
|
|||||||
--exclude='./.phpunit.cache'
|
--exclude='./.phpunit.cache'
|
||||||
--exclude='./build/logs'
|
--exclude='./build/logs'
|
||||||
-C services/nginx/app -cf - .
|
-C services/nginx/app -cf - .
|
||||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||||
|
|
||||||
- name: Resolve dependencies
|
- name: Resolve dependencies
|
||||||
run: |
|
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"
|
||||||
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
|
- name: Verify edge gateway test files
|
||||||
run: >
|
run: >
|
||||||
@@ -327,10 +223,34 @@ jobs:
|
|||||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||||
|
|
||||||
- name: Run edge gateway E2E smoke
|
- name: Run edge gateway E2E smoke
|
||||||
env:
|
run: |
|
||||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
set -euo pipefail
|
||||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||||
run: node scripts/edge-gateway-e2e.mjs
|
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"
|
||||||
|
|
||||||
- name: Tear down local stack
|
- name: Tear down local stack
|
||||||
if: always()
|
if: always()
|
||||||
@@ -338,7 +258,7 @@ jobs:
|
|||||||
|
|
||||||
release-manager-gate:
|
release-manager-gate:
|
||||||
name: Release Manager gate
|
name: Release Manager gate
|
||||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||||
|
|
||||||
@@ -359,7 +279,7 @@ jobs:
|
|||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}")"
|
||||||
response_body="$(cat "$response_file")"
|
response_body="$(cat "$response_file")"
|
||||||
rm -f "$response_file"
|
rm -f "$response_file"
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -13,7 +13,4 @@
|
|||||||
.env.old
|
.env.old
|
||||||
/.tmp/
|
/.tmp/
|
||||||
/.env.staging
|
/.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,7 +40,6 @@ COPY . /var/www/html
|
|||||||
|
|
||||||
# Copy Nginx configuration file
|
# Copy Nginx configuration file
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
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
|
# Install Composer
|
||||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ RUN set -eux; \
|
|||||||
|
|
||||||
COPY services/nginx/app/ /var/www/html/
|
COPY services/nginx/app/ /var/www/html/
|
||||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
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/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -3,8 +3,6 @@ services:
|
|||||||
traefik:
|
traefik:
|
||||||
image: traefik:2.11
|
image: traefik:2.11
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
group_add:
|
|
||||||
- "${DOCKER_SOCKET_GID:-65534}"
|
|
||||||
ports:
|
ports:
|
||||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||||
@@ -104,7 +102,6 @@ services:
|
|||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
container_name: mysql-debug
|
container_name: mysql-debug
|
||||||
profiles: [dev]
|
profiles: [dev]
|
||||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
||||||
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||||
|
|||||||
@@ -3357,9 +3357,6 @@
|
|||||||
},
|
},
|
||||||
"email_notifications_enabled": {
|
"email_notifications_enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
|
||||||
"superuser_new_customer_email_notifications_enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12958,54 +12955,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/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": {
|
"/backups/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -15547,39 +15496,6 @@
|
|||||||
"value"
|
"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": {
|
"BackupsConfigEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -16324,27 +16240,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SlackConfigListResponse": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"data": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/SlackConfigEntry"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"data"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BackupsConfigListResponse": {
|
"BackupsConfigListResponse": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
+203
-3095
File diff suppressed because it is too large
Load Diff
-132545
File diff suppressed because one or more lines are too long
@@ -1,45 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+54
-125
@@ -94,23 +94,9 @@ function directCaddyBaseUrl(baseUrl) {
|
|||||||
return normalizeBaseUrl(url.toString());
|
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) {
|
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||||
const websocketUrl = new URL(String(rawUrl));
|
const websocketUrl = new URL(String(rawUrl));
|
||||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
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") {
|
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||||
websocketUrl.hostname = "edge-broker";
|
websocketUrl.hostname = "edge-broker";
|
||||||
@@ -118,18 +104,6 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
|||||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
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();
|
return websocketUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,38 +555,6 @@ function summarizeStreamMessages(messages, limit = 12) {
|
|||||||
.filter(Boolean);
|
.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() {
|
async function main() {
|
||||||
const scriptPath = fileURLToPath(import.meta.url);
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
const rootDir = await resolveRootDir(scriptPath);
|
const rootDir = await resolveRootDir(scriptPath);
|
||||||
@@ -727,35 +669,22 @@ async function main() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
await waitForCondition(
|
||||||
await waitForCondition(
|
async () => {
|
||||||
async () => {
|
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
token: authToken,
|
||||||
token: authToken,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
detail?.data?.channel_status?.broker?.connected
|
detail?.data?.channel_status?.broker?.connected
|
||||||
|| detail?.data?.metadata?.broker_connected
|
|| detail?.data?.metadata?.broker_connected
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeoutMs: 90_000,
|
timeoutMs: 90_000,
|
||||||
message: "Gateway never established a live broker connection after install.",
|
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 WebSocketImpl = await loadWebSocketImplementation();
|
||||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||||
@@ -778,14 +707,11 @@ async function main() {
|
|||||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForSocketMessage(
|
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
||||||
streamMessages,
|
assert.equal(
|
||||||
(message) => (
|
Boolean(readyMessage?.connected),
|
||||||
message?.type === "gateway.stream.ready" && message?.connected === true
|
true,
|
||||||
) || (
|
"Gateway stream became ready before the broker reported the gateway as connected."
|
||||||
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`, {
|
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||||
@@ -813,24 +739,10 @@ async function main() {
|
|||||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForCondition(
|
await waitForSocketMessage(
|
||||||
async () => {
|
streamMessages,
|
||||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||||
token: authToken,
|
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
let operationSnapshot = null;
|
let operationSnapshot = null;
|
||||||
@@ -853,6 +765,21 @@ async function main() {
|
|||||||
throw new Error(diagnostic);
|
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(
|
await waitForSocketMessage(
|
||||||
streamMessages,
|
streamMessages,
|
||||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||||
@@ -928,20 +855,22 @@ async function main() {
|
|||||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForCondition(
|
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||||
async () => {
|
token: authToken,
|
||||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
});
|
||||||
token: authToken,
|
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||||
});
|
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
: [];
|
||||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
|
||||||
: [];
|
|
||||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
|
||||||
|
|
||||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
assert.ok(
|
||||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||||
},
|
"Gateway logs page did not persist the shell transcript."
|
||||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
);
|
||||||
|
|
||||||
|
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||||
|
assert.ok(
|
||||||
|
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||||
|
"Gateway logs page did not include the shell close audit event."
|
||||||
);
|
);
|
||||||
|
|
||||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||||
|
|||||||
+6
-43
@@ -18,7 +18,6 @@ cd "$repo_root"
|
|||||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||||
project_suffix="$(date +%s)-$$"
|
project_suffix="$(date +%s)-$$"
|
||||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
|
||||||
|
|
||||||
log_dir=".tmp/ci-logs/$suite"
|
log_dir=".tmp/ci-logs/$suite"
|
||||||
mkdir -p "$log_dir"
|
mkdir -p "$log_dir"
|
||||||
@@ -69,33 +68,6 @@ retry_command() {
|
|||||||
done
|
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() {
|
cleanup() {
|
||||||
status="$?"
|
status="$?"
|
||||||
collect_logs "$status"
|
collect_logs "$status"
|
||||||
@@ -115,8 +87,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
configure_ci_docker_subnet
|
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc '
|
docker compose $compose_files exec -T php1 sh -lc '
|
||||||
set -eu
|
set -eu
|
||||||
@@ -139,18 +110,10 @@ tar \
|
|||||||
--exclude='./.phpunit.cache' \
|
--exclude='./.phpunit.cache' \
|
||||||
--exclude='./build/logs' \
|
--exclude='./build/logs' \
|
||||||
-C services/nginx/app -cf - . \
|
-C services/nginx/app -cf - . \
|
||||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
| docker compose $compose_files exec -T php1 tar -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 \
|
docker compose $compose_files exec -T php1 sh -lc \
|
||||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
'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"
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
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 = [
|
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||||
"/edge-agent/install-token/status",
|
"/edge-agent/install-token/status",
|
||||||
"/edge-agent/artifacts/manifest.json",
|
|
||||||
"report_install_status",
|
"report_install_status",
|
||||||
'begin_install_phase "VERIFY_TOKEN"',
|
'begin_install_phase "VERIFY_TOKEN"',
|
||||||
'begin_install_phase "VERIFY_ARTIFACTS"',
|
|
||||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||||
'report_install_status "FAILED"',
|
'report_install_status "FAILED"',
|
||||||
"verify_manifest_artifact",
|
|
||||||
EXPECTED_INSTALL_VERSION,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function normalizeBaseUrl(url) {
|
export function normalizeBaseUrl(url) {
|
||||||
@@ -73,20 +55,13 @@ export function buildChecks(baseUrl, installToken) {
|
|||||||
name: "Ping",
|
name: "Ping",
|
||||||
url: `${normalizedBaseUrl}/ping`,
|
url: `${normalizedBaseUrl}/ping`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Artifact manifest",
|
|
||||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
|
||||||
artifactName: "manifest.json",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Agent PHP artifact",
|
name: "Agent PHP artifact",
|
||||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||||
artifactName: "agent.php",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Service unit artifact",
|
name: "Service unit artifact",
|
||||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||||
artifactName: "truckwash-edge-agent.service",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Installer script",
|
name: "Installer script",
|
||||||
@@ -95,42 +70,6 @@ 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) {
|
export function validateInstallerScriptBody(body) {
|
||||||
const source = String(body || "");
|
const source = String(body || "");
|
||||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||||
@@ -168,17 +107,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
|||||||
|
|
||||||
const checks = buildChecks(baseUrl, installToken);
|
const checks = buildChecks(baseUrl, installToken);
|
||||||
const results = [];
|
const results = [];
|
||||||
let artifactManifest = null;
|
|
||||||
|
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||||
const response = await fetch(check.url);
|
const response = await fetch(check.url);
|
||||||
const body = Buffer.from(await response.arrayBuffer());
|
const body = await response.text();
|
||||||
const result = {
|
const result = {
|
||||||
...check,
|
...check,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
bodyPreview: previewBody(body.toString("utf8")),
|
bodyPreview: previewBody(body),
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
@@ -189,16 +127,8 @@ 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") {
|
if (check.name === "Installer script") {
|
||||||
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_STAGING_BASE_URL,
|
DEFAULT_STAGING_BASE_URL,
|
||||||
EXPECTED_INSTALL_VERSION,
|
|
||||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||||
buildChecks,
|
buildChecks,
|
||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateArtifactBodyAgainstManifest,
|
|
||||||
validateArtifactManifestBody,
|
|
||||||
validateInstallerScriptBody,
|
validateInstallerScriptBody,
|
||||||
} from "./staging-edge-gateway-smoke.mjs";
|
} from "./staging-edge-gateway-smoke.mjs";
|
||||||
|
|
||||||
@@ -35,58 +32,18 @@ test("buildChecks targets the public staging endpoints", () => {
|
|||||||
|
|
||||||
assert.deepEqual(checks.map((check) => check.url), [
|
assert.deepEqual(checks.map((check) => check.url), [
|
||||||
"https://api.truckwash.io:4433/ping",
|
"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/agent.php",
|
||||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
"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", () => {
|
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||||
const script = `
|
const script = `
|
||||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
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"
|
report_install_status "FAILED"
|
||||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
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"
|
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);
|
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_CONFIG_FILE_NAME = "test-gateway.json";
|
||||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||||
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||||
@@ -409,20 +409,14 @@ async function startContainer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (copyConfig) {
|
if (copyConfig) {
|
||||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
await runCommand("docker", [
|
||||||
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
"cp",
|
||||||
try {
|
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||||
await runCommand("docker", [
|
`${containerName}:${containerConfigPath}`,
|
||||||
"cp",
|
], {
|
||||||
configFilePath,
|
cwd: rootDir,
|
||||||
`${containerName}:${containerConfigPath}`,
|
stdio: "inherit",
|
||||||
], {
|
});
|
||||||
cwd: rootDir,
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
await runCommand("docker", ["start", containerName], {
|
await runCommand("docker", ["start", containerName], {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
|
|||||||
Vendored
-58
@@ -754,60 +754,6 @@ 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) {
|
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1470,10 +1416,6 @@ export async function handleAgentCommand(command, deps = {}) {
|
|||||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||||
case "SET_RELAY_STATE":
|
case "SET_RELAY_STATE":
|
||||||
return await setRelayState(command.payload || {}, fetchImpl);
|
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":
|
case "RUN_UPDATE":
|
||||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||||
case "UNINSTALL_AGENT":
|
case "UNINSTALL_AGENT":
|
||||||
|
|||||||
@@ -127,39 +127,6 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
|||||||
assert.equal(switched.on, false);
|
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 () => {
|
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) => {
|
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||||
|
|||||||
@@ -296,12 +296,6 @@ export function createBrokerServer(options = {}) {
|
|||||||
? async (_gatewayId, payload = {}) => payload
|
? async (_gatewayId, payload = {}) => payload
|
||||||
: async (gatewayId, payload = {}) =>
|
: async (gatewayId, payload = {}) =>
|
||||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, 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 broadcastGatewayEvent = (gatewayId, message) => {
|
||||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||||
@@ -859,11 +853,6 @@ export function createBrokerServer(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "MACHINE_SIGNAL") {
|
|
||||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||||
if (!sessionRecord) {
|
if (!sessionRecord) {
|
||||||
|
|||||||
@@ -766,51 +766,6 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
|||||||
await broker.close();
|
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 () => {
|
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||||
const broker = createBrokerServer({
|
const broker = createBrokerServer({
|
||||||
authMode: "stub",
|
authMode: "stub",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -133,14 +133,8 @@ class attachments implements attachments_i
|
|||||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||||
{
|
{
|
||||||
$options = $this->normalizeAttachmentOptions($options);
|
$options = $this->normalizeAttachmentOptions($options);
|
||||||
$rawType = trim($type, '`');
|
|
||||||
$objectTypes = array_values(array_unique([
|
|
||||||
$rawType,
|
|
||||||
'`' . $rawType . '`',
|
|
||||||
]));
|
|
||||||
|
|
||||||
return (new object_attachments_o())->getFieldsWhereIn([
|
return (new object_attachments_o())->getFieldsWhereIn([
|
||||||
'object_type' => $objectTypes,
|
'object_type' => $type,
|
||||||
'object_id' => $object_ids,
|
'object_id' => $object_ids,
|
||||||
'deleted_at' => null
|
'deleted_at' => null
|
||||||
], $options);
|
], $options);
|
||||||
|
|||||||
@@ -121,16 +121,6 @@ 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
|
private static function bulkEnvData(array $env): array
|
||||||
{
|
{
|
||||||
$data = [];
|
$data = [];
|
||||||
@@ -169,11 +159,6 @@ class coolify_api_client
|
|||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
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
|
public function deleteService(string $uuid): array
|
||||||
{
|
{
|
||||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class coolify_manager
|
|||||||
'targets' => $this->listTargets(),
|
'targets' => $this->listTargets(),
|
||||||
'availability' => $this->availabilitySummary(),
|
'availability' => $this->availabilitySummary(),
|
||||||
'load_balancer' => $this->loadBalancerSummary(),
|
'load_balancer' => $this->loadBalancerSummary(),
|
||||||
|
'github_runners' => $this->githubRunnerSummary(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1372,54 @@ class coolify_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function githubRunnerSummary(): array
|
||||||
|
{
|
||||||
|
$this->ensureSchema();
|
||||||
|
$repositories = $this->githubRunnerRepositories([]);
|
||||||
|
$labels = $this->githubRunnerLabels(null);
|
||||||
|
$countPerRepo = $this->githubRunnerCount(null);
|
||||||
|
$serviceUuid = $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
|
||||||
|
$token = $this->githubRunnerToken([]);
|
||||||
|
$config = [
|
||||||
|
'configured' => $serviceUuid !== null,
|
||||||
|
'service_uuid' => $serviceUuid,
|
||||||
|
'service_name' => $this->githubRunnerServiceName(null),
|
||||||
|
'repositories' => $repositories,
|
||||||
|
'labels' => $labels,
|
||||||
|
'count_per_repo' => $countPerRepo,
|
||||||
|
'desired_runner_count' => count($repositories) * $countPerRepo,
|
||||||
|
'compose_hash' => $this->composeHash($this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo)),
|
||||||
|
'token_set' => $token !== '',
|
||||||
|
'token_source' => trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: '')) !== '' ? 'env' : ($token !== '' ? 'config' : null),
|
||||||
|
];
|
||||||
|
|
||||||
|
$base = [
|
||||||
|
'status' => $serviceUuid === null ? 'not_configured' : 'configured',
|
||||||
|
'config' => $config,
|
||||||
|
'service' => null,
|
||||||
|
'last_error' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($serviceUuid === null || !coolify_schema_bootstrap::tablesExist()) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$instanceId = $this->defaultInstanceId();
|
||||||
|
$instance = $this->getInstance($instanceId);
|
||||||
|
$service = $this->clientForInstance($instance)->getService($serviceUuid);
|
||||||
|
return array_replace($base, [
|
||||||
|
'status' => 'ok',
|
||||||
|
'service' => self::redactCoolifyResponse($service),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
return array_replace($base, [
|
||||||
|
'status' => 'unknown',
|
||||||
|
'last_error' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
|
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
|
||||||
{
|
{
|
||||||
$this->ensureSchema();
|
$this->ensureSchema();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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_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 ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
|
||||||
public const MAX_AGE_SECONDS = '86400';
|
public const MAX_AGE_SECONDS = '86400';
|
||||||
|
|
||||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||||
@@ -26,9 +25,6 @@ class cors_policy
|
|||||||
'https://localhost:4433',
|
'https://localhost:4433',
|
||||||
'https://twdev.jeppeb.dk',
|
'https://twdev.jeppeb.dk',
|
||||||
'http://localhost:5173',
|
'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
|
public static function normalizeOrigin(?string $value): string
|
||||||
@@ -129,9 +125,7 @@ class cors_policy
|
|||||||
'Access-Control-Allow-Credentials' => 'true',
|
'Access-Control-Allow-Credentials' => 'true',
|
||||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
|
||||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||||
'Timing-Allow-Origin' => $origin,
|
|
||||||
'Vary' => 'Origin',
|
'Vary' => 'Origin',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,10 +137,6 @@ class customer_mass_import_service
|
|||||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
|
||||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function normalizePositiveInt(mixed $value): ?int
|
protected function normalizePositiveInt(mixed $value): ?int
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace classes;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class customer_order_product_policy
|
|
||||||
{
|
|
||||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
|
||||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
|
||||||
|
|
||||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
|
||||||
{
|
|
||||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
|
||||||
if ($message !== null) {
|
|
||||||
throw new RuntimeException($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
|
||||||
{
|
|
||||||
$context = self::loadOrderProductContext($orderId, $productId);
|
|
||||||
if ($context === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
|
||||||
? self::ONLY_TANKCLEANING_MESSAGE
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
|
||||||
{
|
|
||||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isTankCleaningProductRow(array $row): bool
|
|
||||||
{
|
|
||||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
|
||||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if ($orderId < 1 || $productId < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT
|
|
||||||
o.id AS order_id,
|
|
||||||
o.customer_id AS customer_number,
|
|
||||||
p.id AS product_id,
|
|
||||||
p.name AS product_name,
|
|
||||||
p.category AS product_category,
|
|
||||||
c.name AS category_name,
|
|
||||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN products p ON p.id = {$productId}
|
|
||||||
LEFT JOIN categories c ON c.id = p.category
|
|
||||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
|
||||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
|
||||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
|
||||||
WHERE o.id = {$orderId}
|
|
||||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
|
||||||
LIMIT 1
|
|
||||||
";
|
|
||||||
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if (!$result || $result->num_rows < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return is_array($row) ? $row : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
|
||||||
{
|
|
||||||
$haystack = strtolower(trim(
|
|
||||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
|
||||||
(string)($row['category_name'] ?? '')
|
|
||||||
));
|
|
||||||
|
|
||||||
foreach ($terms as $term) {
|
|
||||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace classes;
|
|
||||||
|
|
||||||
use objects\orders_o;
|
|
||||||
use objects\products_o;
|
|
||||||
use objects\users_o;
|
|
||||||
|
|
||||||
class customer_product_rule_service
|
|
||||||
{
|
|
||||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
|
||||||
|
|
||||||
private const ADDON_CATEGORY_ID = 4;
|
|
||||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{rule:string,message:string}|null
|
|
||||||
*/
|
|
||||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
|
||||||
{
|
|
||||||
$order = (new orders_o())->getOrderById($orderId);
|
|
||||||
if (!$order->exists()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$product = (new products_o())->getProductById($productId);
|
|
||||||
if (!$product->exists()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
|
||||||
if (!$customer->exists()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$categoryId = (int)$product->category->value();
|
|
||||||
$categoryName = $this->categoryName($categoryId);
|
|
||||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
|
||||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
|
||||||
|
|
||||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
|
||||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
|
||||||
return $this->violation('restrictAdditionalServices');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
|
||||||
return $this->violation('restrictTankCleaning');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
|
||||||
return $this->violation('onlyTankCleaning');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
|
||||||
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
|
|
||||||
return $this->violation('restrictSpotFree');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
|
||||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
|
||||||
return $this->violation('restrictInteriorCleaning');
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{rule:string,message:string}
|
|
||||||
*/
|
|
||||||
private function violation(string $rule): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'rule' => $rule,
|
|
||||||
'message' => self::BLOCK_MESSAGE,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
|
||||||
{
|
|
||||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
|
||||||
{
|
|
||||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function searchableProductText(products_o $product, string $categoryName): string
|
|
||||||
{
|
|
||||||
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $terms
|
|
||||||
*/
|
|
||||||
private function containsAny(string $value, array $terms): bool
|
|
||||||
{
|
|
||||||
foreach ($terms as $term) {
|
|
||||||
if ($term !== '' && str_contains($value, $term)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function categoryName(int $categoryId): string
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if ($categoryId <= 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
|
||||||
if (!$result || $result->num_rows === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return strtolower((string)($row['name'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function countStandaloneOrderItems(int $orderId): int
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
$result = $db->query(
|
|
||||||
'SELECT COUNT(*) AS item_count
|
|
||||||
FROM order_items
|
|
||||||
WHERE order_id = ' . $orderId . '
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
|
||||||
);
|
|
||||||
if (!$result) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)($row['item_count'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,14 +34,6 @@ class departments_schema_bootstrap
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
|
||||||
$db->query(
|
|
||||||
"ALTER TABLE departments
|
|
||||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
|
||||||
AFTER archived"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||||
$db->query(
|
$db->query(
|
||||||
"ALTER TABLE departments
|
"ALTER TABLE departments
|
||||||
|
|||||||
@@ -172,8 +172,7 @@ class economic implements economic_i
|
|||||||
string $email,
|
string $email,
|
||||||
int $phone,
|
int $phone,
|
||||||
?int $mobile_phone = null,
|
?int $mobile_phone = null,
|
||||||
object|array|null $company_information = null,
|
object|array|null $company_information = null
|
||||||
?string $ean = null
|
|
||||||
): object
|
): object
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -197,37 +196,10 @@ class economic implements economic_i
|
|||||||
];
|
];
|
||||||
|
|
||||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
|
||||||
if ($normalized_ean !== null) {
|
|
||||||
$payload['ean'] = $normalized_ean;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->customers->customers->create($payload);
|
return $this->customers->customers->create($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function normalizeCustomerEan(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
|
||||||
if (!is_string($digits)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$digits = trim($digits);
|
|
||||||
if ($digits === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($digits) > 13) {
|
|
||||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||||
{
|
{
|
||||||
if ($company_information === null) {
|
if ($company_information === null) {
|
||||||
|
|||||||
@@ -240,13 +240,7 @@ class economic_transfer_executor
|
|||||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $collected_order_invoices->asArray();
|
return $collected_order_invoices->asArray();
|
||||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
|
||||||
if ($transfer_metrics !== null) {
|
|
||||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -389,15 +389,6 @@ class economic_v2_distribution_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
|
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
|
||||||
if ($discount_row === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
|
||||||
$fixed_price = (float)$discount_row['fixed_price'];
|
|
||||||
$order_discount_total += (($base_price - $fixed_price) * $quantity);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||||
if ($discount_percentage <= 0) {
|
if ($discount_percentage <= 0) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1529,12 +1520,6 @@ class economic_v2_distribution_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
||||||
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
|
||||||
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
|
||||||
$total += $line_price;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||||
if ($discount_percentage > 0) {
|
if ($discount_percentage > 0) {
|
||||||
$line_price *= (1 - ($discount_percentage / 100));
|
$line_price *= (1 - ($discount_percentage / 100));
|
||||||
@@ -1552,10 +1537,7 @@ class economic_v2_distribution_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
||||||
if ($direct !== null && (
|
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
||||||
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
|
||||||
|| (int)($direct['discount'] ?? 0) > 0
|
|
||||||
)) {
|
|
||||||
return $this->discount_resolution_cache[$cache_key] = $direct;
|
return $this->discount_resolution_cache[$cache_key] = $direct;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class economic_v2_schema_bootstrap
|
|||||||
is_category TINYINT(1) NOT NULL,
|
is_category TINYINT(1) NOT NULL,
|
||||||
object_id VARCHAR(64) NOT NULL,
|
object_id VARCHAR(64) NOT NULL,
|
||||||
discount INT NOT NULL,
|
discount INT NOT NULL,
|
||||||
fixed_price INT NULL DEFAULT NULL,
|
|
||||||
effective_from DATETIME NOT NULL,
|
effective_from DATETIME NOT NULL,
|
||||||
effective_to DATETIME NULL,
|
effective_to DATETIME NULL,
|
||||||
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
||||||
@@ -84,14 +83,6 @@ class economic_v2_schema_bootstrap
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
|
|
||||||
$db->query(
|
|
||||||
"ALTER TABLE customer_discount_override_versions
|
|
||||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
|
||||||
AFTER discount"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,3 +106,4 @@ class economic_v2_schema_bootstrap
|
|||||||
return ((int)($row['c'] ?? 0)) > 0;
|
return ((int)($row['c'] ?? 0)) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,7 @@ class economic_v2_versioning_service
|
|||||||
string $source = 'live.discount_override',
|
string $source = 'live.discount_override',
|
||||||
float $confidence = 1.0,
|
float $confidence = 1.0,
|
||||||
bool $inferred = false,
|
bool $inferred = false,
|
||||||
array $metadata = [],
|
array $metadata = []
|
||||||
?int $fixed_price = null
|
|
||||||
): array {
|
): array {
|
||||||
$identity = [
|
$identity = [
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
@@ -145,7 +144,7 @@ class economic_v2_versioning_service
|
|||||||
'object_id' => (string)$object_id,
|
'object_id' => (string)$object_id,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
if ($discount === null || (int)$discount === 0) {
|
||||||
return $this->closeActiveVersion(
|
return $this->closeActiveVersion(
|
||||||
'customer_discount_override_versions',
|
'customer_discount_override_versions',
|
||||||
$identity,
|
$identity,
|
||||||
@@ -162,7 +161,6 @@ class economic_v2_versioning_service
|
|||||||
$identity,
|
$identity,
|
||||||
[
|
[
|
||||||
'discount' => (int)$discount,
|
'discount' => (int)$discount,
|
||||||
'fixed_price' => $is_category ? null : $fixed_price,
|
|
||||||
],
|
],
|
||||||
$this->normalizeDatetime($effective_from),
|
$this->normalizeDatetime($effective_from),
|
||||||
$source,
|
$source,
|
||||||
@@ -413,11 +411,8 @@ class economic_v2_versioning_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Discount overrides current state.
|
// Discount overrides current state.
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
|
||||||
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
||||||
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
|
|
||||||
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
||||||
($has_override_fixed_price ? ', po.fixed_price' : '') .
|
|
||||||
($has_override_created_at ? ', po.created_at' : '');
|
($has_override_created_at ? ', po.created_at' : '');
|
||||||
$discount_rows = $this->fetchAll(
|
$discount_rows = $this->fetchAll(
|
||||||
"SELECT $discount_cols
|
"SELECT $discount_cols
|
||||||
@@ -439,8 +434,7 @@ class economic_v2_versioning_service
|
|||||||
'backfill.current_discount_override',
|
'backfill.current_discount_override',
|
||||||
$confidence,
|
$confidence,
|
||||||
true,
|
true,
|
||||||
['table' => 'price_overrides'],
|
['table' => 'price_overrides']
|
||||||
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
|
|
||||||
);
|
);
|
||||||
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
||||||
}
|
}
|
||||||
@@ -713,3 +707,4 @@ class economic_v2_versioning_service
|
|||||||
$bucket[$action]++;
|
$bucket[$action]++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||||
{
|
{
|
||||||
if (self::isFakeDeliveryEnabled()) {
|
if (self::isFakeDeliveryEnabled()) {
|
||||||
self::recordFakeDelivery([
|
self::$fake_deliveries[] = [
|
||||||
'to' => $to,
|
'to' => $to,
|
||||||
'recipient_name' => $recipient_name,
|
'recipient_name' => $recipient_name,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
]);
|
];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,72 +225,6 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
public static function resetFakeDeliveries(): void
|
public static function resetFakeDeliveries(): void
|
||||||
{
|
{
|
||||||
self::$fake_deliveries = [];
|
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
|
private static function isFakeDeliveryEnabled(): bool
|
||||||
@@ -577,47 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
$this->attachments
|
$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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,17 +92,12 @@ class error_report_service
|
|||||||
throw new RuntimeException('Data collection acceptance is required.');
|
throw new RuntimeException('Data collection acceptance is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||||
|
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
|
||||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||||
$runtimeContext['screenshot_attachment'] = [
|
|
||||||
'status' => $storedScreenshot['status'],
|
|
||||||
'attached' => $storedScreenshot['key'] !== '',
|
|
||||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
|
||||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->execute(
|
$this->execute(
|
||||||
"INSERT INTO error_reports (
|
"INSERT INTO error_reports (
|
||||||
@@ -300,67 +295,6 @@ class error_report_service
|
|||||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
|
||||||
{
|
|
||||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
|
||||||
return $this->emptyScreenshotAttachment('invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
$dataUri = trim((string)($value ?? ''));
|
|
||||||
if ($dataUri === '') {
|
|
||||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
|
||||||
} catch (RuntimeException $exception) {
|
|
||||||
$message = strtolower($exception->getMessage());
|
|
||||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return $this->emptyScreenshotAttachment('storage_failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
|
||||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
|
||||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
|
||||||
'status' => 'stored',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emptyScreenshotAttachment(string $status): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'key' => '',
|
|
||||||
'mime_type' => '',
|
|
||||||
'size_bytes' => 0,
|
|
||||||
'status' => $status,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function contextScreenshotStatus(array $context): ?string
|
|
||||||
{
|
|
||||||
$attachment = $context['screenshot_attachment'] ?? null;
|
|
||||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
|
||||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
|
||||||
|
|
||||||
return $this->normalizeEmptyScreenshotStatus($status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
|
||||||
{
|
|
||||||
$status = strtolower(trim((string)$status));
|
|
||||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
|
||||||
return $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function runtimeContext(array $payload, array $context): array
|
private function runtimeContext(array $payload, array $context): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -498,10 +432,6 @@ class error_report_service
|
|||||||
|
|
||||||
private function publicReport(array $row, bool $includeDetail): array
|
private function publicReport(array $row, bool $includeDetail): array
|
||||||
{
|
{
|
||||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
|
||||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
|
||||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
|
||||||
|
|
||||||
$report = [
|
$report = [
|
||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
'status' => (string)$row['status'],
|
'status' => (string)$row['status'],
|
||||||
@@ -519,10 +449,10 @@ class error_report_service
|
|||||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||||
'frontend_version' => $row['frontend_version'] ?? null,
|
'frontend_version' => $row['frontend_version'] ?? null,
|
||||||
'api_version' => $row['api_version'] ?? null,
|
'api_version' => $row['api_version'] ?? null,
|
||||||
'screenshot' => $hasScreenshot ? [
|
'screenshot' => [
|
||||||
'mime_type' => $screenshotMimeType,
|
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||||
'size_bytes' => $screenshotSizeBytes,
|
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||||
] : null,
|
],
|
||||||
'answers' => [
|
'answers' => [
|
||||||
'before_error' => $row['before_error'] ?? '',
|
'before_error' => $row['before_error'] ?? '',
|
||||||
'expected' => $row['expected'] ?? '',
|
'expected' => $row['expected'] ?? '',
|
||||||
@@ -537,11 +467,8 @@ class error_report_service
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($includeDetail) {
|
if ($includeDetail) {
|
||||||
if ($hasScreenshot) {
|
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
|
||||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
|
||||||
}
|
|
||||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ class gateway_shelly_transport implements shelly_transport_i
|
|||||||
return match ($endpoint) {
|
return match ($endpoint) {
|
||||||
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
||||||
'/v2/devices/api/set/switch' => $this->handleSetSwitch($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),
|
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,52 +95,6 @@ class gateway_shelly_transport implements shelly_transport_i
|
|||||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
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
|
* @param array<string,mixed> $status
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class invoice_period_flag_service
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
invoice_period_flag_schema_bootstrap::ensureTables();
|
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createManualFlag(array $payload, int $userId): array
|
public function createManualFlag(array $payload, int $userId): array
|
||||||
@@ -689,7 +688,6 @@ class invoice_period_flag_service
|
|||||||
o.po AS order_po,
|
o.po AS order_po,
|
||||||
o.notes AS order_notes,
|
o.notes AS order_notes,
|
||||||
o.department_id,
|
o.department_id,
|
||||||
d.custom_pricing_only AS department_custom_pricing_only,
|
|
||||||
o.reg_1,
|
o.reg_1,
|
||||||
o.invoice_collection_id,
|
o.invoice_collection_id,
|
||||||
o.wash_id,
|
o.wash_id,
|
||||||
@@ -713,7 +711,6 @@ class invoice_period_flag_service
|
|||||||
c.name AS category_name,
|
c.name AS category_name,
|
||||||
pdp.price AS department_price,
|
pdp.price AS department_price,
|
||||||
product_discount.percentage AS product_discount_percentage,
|
product_discount.percentage AS product_discount_percentage,
|
||||||
product_discount.fixed_price AS product_fixed_price,
|
|
||||||
category_discount.percentage AS category_discount_percentage
|
category_discount.percentage AS category_discount_percentage
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@@ -723,12 +720,11 @@ class invoice_period_flag_service
|
|||||||
GROUP BY customer_number
|
GROUP BY customer_number
|
||||||
) u ON u.customer_number = o.customer_id
|
) u ON u.customer_number = o.customer_id
|
||||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||||
LEFT JOIN departments d ON d.id = o.department_id
|
|
||||||
LEFT JOIN products p ON p.id = oi.product_id
|
LEFT JOIN products p ON p.id = oi.product_id
|
||||||
LEFT JOIN categories c ON c.id = p.category
|
LEFT JOIN categories c ON c.id = p.category
|
||||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||||
FROM price_overrides po
|
FROM price_overrides po
|
||||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||||
WHERE po.is_category = 0
|
WHERE po.is_category = 0
|
||||||
@@ -1925,19 +1921,7 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function calculateExpectedPrice(array $row): int
|
private function calculateExpectedPrice(array $row): int
|
||||||
{
|
{
|
||||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||||
if ($customMissingPrice) {
|
|
||||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
|
||||||
if ($fixedPrice !== null) {
|
|
||||||
return $fixedPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
$base = $row['department_price'] !== null
|
|
||||||
? (int)$row['department_price']
|
|
||||||
: (int)($row['product_base_price'] ?? 0);
|
|
||||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||||
return (int)round($base * (1 - ($discount / 100)));
|
return (int)round($base * (1 - ($discount / 100)));
|
||||||
}
|
}
|
||||||
@@ -1945,18 +1929,13 @@ class invoice_period_flag_service
|
|||||||
private function priceBreakdown(array $row, int $expected): array
|
private function priceBreakdown(array $row, int $expected): array
|
||||||
{
|
{
|
||||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
|
||||||
$discount = $this->discountBreakdown($row);
|
$discount = $this->discountBreakdown($row);
|
||||||
if ($customMissingPrice) {
|
|
||||||
$discount['applied_discount_percentage'] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||||
'department_price' => $departmentPrice,
|
'department_price' => $departmentPrice,
|
||||||
'effective_base_price' => $base,
|
'effective_base_price' => $base,
|
||||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
|
||||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||||
@@ -1965,36 +1944,21 @@ class invoice_period_flag_service
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
|
||||||
{
|
|
||||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function discountBreakdown(array $row): array
|
private function discountBreakdown(array $row): array
|
||||||
{
|
{
|
||||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
|
||||||
? 0
|
|
||||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'product_discount_percentage' => $productDiscount,
|
'product_discount_percentage' => $productDiscount,
|
||||||
'category_discount_percentage' => $categoryDiscount,
|
'category_discount_percentage' => $categoryDiscount,
|
||||||
'economic_customer_discount_percentage' => $economicDiscount,
|
'economic_customer_discount_percentage' => $economicDiscount,
|
||||||
'applied_discount_percentage' => $appliedDiscount,
|
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rowProductFixedPrice(array $row): ?int
|
|
||||||
{
|
|
||||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
|
||||||
? (int)$row['product_fixed_price']
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function economicCustomerDiscountPercentage(array $row): int
|
private function economicCustomerDiscountPercentage(array $row): int
|
||||||
{
|
{
|
||||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||||
@@ -2072,7 +2036,8 @@ class invoice_period_flag_service
|
|||||||
|
|
||||||
private function rowIsTankCleaningProduct(array $row): bool
|
private function rowIsTankCleaningProduct(array $row): bool
|
||||||
{
|
{
|
||||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
return (int)($row['product_category'] ?? 0) === 5
|
||||||
|
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isIncludedOrderItem(array $row): bool
|
private function isIncludedOrderItem(array $row): bool
|
||||||
|
|||||||
@@ -10,22 +10,6 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
|||||||
|
|
||||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
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
|
* The configuration of the module
|
||||||
* @var licenseplaterecognizer_c
|
* @var licenseplaterecognizer_c
|
||||||
@@ -35,25 +19,12 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
* API URL
|
* API URL
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
private string $api_url;
|
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||||
|
|
||||||
/**
|
|
||||||
* @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->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
$this->config = new licenseplaterecognizer_c();
|
||||||
|
|
||||||
if ($load_config) {
|
|
||||||
$this->config = new licenseplaterecognizer_c();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +33,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function requireModuleEnabled(): void
|
public function requireModuleEnabled(): void
|
||||||
{
|
{
|
||||||
if (!$this->runtimeConfig()['enabled']) {
|
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,516 +45,54 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function licenseplaterecognizer(string $base64_image): array
|
public function licenseplaterecognizer(string $base64_image): array
|
||||||
{
|
{
|
||||||
return $this->recognizePlate(
|
$image_processor = new image_processor();
|
||||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
|
||||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
//ADD PARAMETER IN REQUEST LIKE regions
|
||||||
{
|
$data = array(
|
||||||
return $this->recognizePlate(
|
'upload' => $base64_image,
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
//'regions' => 'dk' // Optional
|
||||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
|
||||||
),
|
|
||||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
// Prepare new cURL resource
|
||||||
{
|
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||||
return $this->recognizePlate(
|
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
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);
|
||||||
|
|
||||||
|
// Set HTTP Header for POST request
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||||
|
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
// Submit the POST request and close cURL session handle
|
||||||
{
|
$result = curl_exec($ch);
|
||||||
return $this->recognizePlate(
|
curl_close($ch);
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
// Print the response from the server
|
||||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
if ($result === false) {
|
||||||
)
|
throw new Exception('Error in API request.');
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$response_data = json_decode($result, true);
|
||||||
* @throws Exception
|
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||||
*/
|
return [
|
||||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
'success' => true,
|
||||||
{
|
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||||
$started_at = microtime(true);
|
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||||
$this->last_timings = [];
|
'raw_response' => $response_data,
|
||||||
$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:',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
} else {
|
||||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
return [
|
||||||
}
|
|
||||||
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,
|
'success' => false,
|
||||||
'message' => 'No license plate detected.',
|
'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
|
* @inheritDoc
|
||||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||||
@@ -591,8 +100,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function get_usage(): licenseplaterecognizer_info
|
public function get_usage(): licenseplaterecognizer_info
|
||||||
{
|
{
|
||||||
|
// Require the module to be enabled
|
||||||
$this->requireModuleEnabled();
|
$this->requireModuleEnabled();
|
||||||
$api_key = $this->runtimeConfig()['api_key'];
|
|
||||||
$curl = curl_init();
|
$curl = curl_init();
|
||||||
curl_setopt_array($curl, array(
|
curl_setopt_array($curl, array(
|
||||||
CURLOPT_URL => $this->api_url . '/info/',
|
CURLOPT_URL => $this->api_url . '/info/',
|
||||||
@@ -603,9 +112,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => array(
|
||||||
'Authorization: Token ' . $api_key,
|
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||||
],
|
),
|
||||||
));
|
));
|
||||||
$response = curl_exec($curl);
|
$response = curl_exec($curl);
|
||||||
curl_close($curl);
|
curl_close($curl);
|
||||||
@@ -615,4 +124,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
}
|
}
|
||||||
return new licenseplaterecognizer_info($response_data);
|
return new licenseplaterecognizer_info($response_data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?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,12 +47,8 @@ class pdf_store implements minio_pdfs_i
|
|||||||
*/
|
*/
|
||||||
public function download(string $file): string
|
public function download(string $file): string
|
||||||
{
|
{
|
||||||
if ($this->shouldUseLocalTestStorage()) {
|
|
||||||
return $this->getLocalTestObjectPath($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = '/tmp/' . $file;
|
$path = '/tmp/' . $file;
|
||||||
self::getS3Client()->getObject([
|
$result = self::getS3Client()->getObject([
|
||||||
'Bucket' => self::getBucket(),
|
'Bucket' => self::getBucket(),
|
||||||
'Key' => $file,
|
'Key' => $file,
|
||||||
'SaveAs' => $path
|
'SaveAs' => $path
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace classes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures additive schema for customer product price overrides.
|
|
||||||
*/
|
|
||||||
class price_overrides_schema_bootstrap
|
|
||||||
{
|
|
||||||
private static bool $initialized = false;
|
|
||||||
|
|
||||||
public static function ensureColumns(): void
|
|
||||||
{
|
|
||||||
if (self::$initialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::tableExists($db, 'price_overrides')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
|
|
||||||
$db->query(
|
|
||||||
"ALTER TABLE price_overrides
|
|
||||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
|
||||||
AFTER percentage"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function tableExists(object $db, string $table): bool
|
|
||||||
{
|
|
||||||
$table = self::escapeIdentifier($table);
|
|
||||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
|
||||||
|
|
||||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)$result->num_rows > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function columnExists(object $db, string $table, string $column): bool
|
|
||||||
{
|
|
||||||
$table = self::escapeIdentifier($table);
|
|
||||||
$column = self::escapeIdentifier($column);
|
|
||||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
|
||||||
|
|
||||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)$result->num_rows > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function escapeIdentifier(string $value): string
|
|
||||||
{
|
|
||||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1434,8 +1434,6 @@ class release_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
|
|
||||||
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
|
|
||||||
$checked = [];
|
$checked = [];
|
||||||
try {
|
try {
|
||||||
foreach ($gateInput['api_ping_paths'] as $path) {
|
foreach ($gateInput['api_ping_paths'] as $path) {
|
||||||
@@ -1444,19 +1442,9 @@ class release_manager
|
|||||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||||
throw new RuntimeException(sprintf('%s returned success=false.', $path));
|
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[] = [
|
$checked[] = [
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'status' => $json['status'],
|
'status' => $json['status'],
|
||||||
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
@@ -1483,24 +1471,10 @@ class release_manager
|
|||||||
'context' => [
|
'context' => [
|
||||||
'api_base_url' => $apiBaseUrl,
|
'api_base_url' => $apiBaseUrl,
|
||||||
'checked' => $checked,
|
'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
|
private function releaseGateFetchJson(string $baseUrl, string $path): array
|
||||||
{
|
{
|
||||||
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
||||||
@@ -2092,7 +2066,11 @@ class release_manager
|
|||||||
|
|
||||||
$eventId = (int)$event['id'];
|
$eventId = (int)$event['id'];
|
||||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||||
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
|
return [
|
||||||
|
'step_status' => 'passed',
|
||||||
|
'message' => 'Automatic container update is already being processed for this commit.',
|
||||||
|
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2174,67 +2152,6 @@ 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
|
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||||
{
|
{
|
||||||
$channelId = (int)$input['channel_id'];
|
$channelId = (int)$input['channel_id'];
|
||||||
@@ -6315,7 +6232,6 @@ class release_manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
||||||
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
|
|
||||||
return [
|
return [
|
||||||
'service_uuid' => $serviceUuid,
|
'service_uuid' => $serviceUuid,
|
||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
@@ -6325,76 +6241,9 @@ class release_manager
|
|||||||
'updated' => self::redactPayload($update ?? []),
|
'updated' => self::redactPayload($update ?? []),
|
||||||
'runtime_env' => $runtimeEnvUpdate,
|
'runtime_env' => $runtimeEnvUpdate,
|
||||||
'deployment' => self::redactPayload($deployment),
|
'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
|
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
||||||
{
|
{
|
||||||
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
||||||
@@ -6403,7 +6252,6 @@ class release_manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resourceType === 'application') {
|
if ($resourceType === 'application') {
|
||||||
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
|
|
||||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||||
} else {
|
} else {
|
||||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||||
@@ -6416,45 +6264,12 @@ 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
|
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||||
{
|
{
|
||||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||||
$env = $contextEnv;
|
$env = $contextEnv;
|
||||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
|
||||||
|
|
||||||
if ($app !== 'api') {
|
if ($app !== 'api') {
|
||||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
|
||||||
return $env;
|
return $env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6476,37 +6291,21 @@ class release_manager
|
|||||||
$this->appendRuntimeEnvValue($env, $key, $value);
|
$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 = array_replace($env, $contextEnv);
|
||||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
|
||||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
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
|
private function releaseCoolifyContextEnv(array $context): array
|
||||||
{
|
{
|
||||||
$env = [];
|
$env = [];
|
||||||
@@ -7214,13 +7013,6 @@ class release_manager
|
|||||||
|
|
||||||
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
||||||
{
|
{
|
||||||
foreach (['commit_sha', 'commit'] as $key) {
|
|
||||||
$value = trim((string)($target[$key] ?? ''));
|
|
||||||
if ($value !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
'coolify_git_commit_sha',
|
'coolify_git_commit_sha',
|
||||||
'git_commit_sha',
|
'git_commit_sha',
|
||||||
@@ -9136,7 +8928,7 @@ class release_manager
|
|||||||
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
|
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
|
||||||
if (is_array($payload[$key] ?? null)) {
|
if (is_array($payload[$key] ?? null)) {
|
||||||
return $this->payloadRows($payload[$key]);
|
return $this->payloadRows($payload[$key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class router
|
|||||||
}
|
}
|
||||||
// Regex
|
// Regex
|
||||||
$route = str_replace('/', '\/', $route);
|
$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)) {
|
if (preg_match('/^' . $route . '$/', $this->url)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class selfserve_schema_bootstrap
|
|||||||
session_id INT NOT NULL,
|
session_id INT NOT NULL,
|
||||||
task_id INT NULL,
|
task_id INT NULL,
|
||||||
task_text VARCHAR(255) NOT NULL,
|
task_text VARCHAR(255) NOT NULL,
|
||||||
description TEXT NULL,
|
description VARCHAR(255) NULL,
|
||||||
services JSON NULL,
|
services JSON NULL,
|
||||||
buttons JSON NULL,
|
buttons JSON NULL,
|
||||||
dynamic_image_id INT NULL,
|
dynamic_image_id INT NULL,
|
||||||
@@ -197,18 +197,6 @@ class selfserve_schema_bootstrap
|
|||||||
'gate_ref_id',
|
'gate_ref_id',
|
||||||
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
'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(
|
self::ensureColumn(
|
||||||
'selfserve_wash_session_tasks',
|
'selfserve_wash_session_tasks',
|
||||||
'dynamic_images_vehicle_type',
|
'dynamic_images_vehicle_type',
|
||||||
@@ -251,49 +239,4 @@ class selfserve_schema_bootstrap
|
|||||||
}
|
}
|
||||||
$db->query($alterSql);
|
$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,8 +16,6 @@ class shelly implements shelly_i
|
|||||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
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>>
|
* @var array<int,array<string,mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -180,9 +178,6 @@ class shelly implements shelly_i
|
|||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
'Content-Type: application/json',
|
'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
|
// Execute the request
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
// Get the status code
|
// Get the status code
|
||||||
@@ -229,9 +224,6 @@ class shelly implements shelly_i
|
|||||||
);
|
);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_HTTPGET, 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);
|
$response = curl_exec($ch);
|
||||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|||||||
@@ -6,25 +6,13 @@ use GuzzleHttp\Client;
|
|||||||
use interfaces\notification_i;
|
use interfaces\notification_i;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
use slack\slack_c;
|
|
||||||
use traits\notification_t;
|
use traits\notification_t;
|
||||||
|
|
||||||
require_once WD . '/modules/slack/slack_c.php';
|
|
||||||
|
|
||||||
class slack implements notification_i
|
class slack implements notification_i
|
||||||
{
|
{
|
||||||
use notification_t;
|
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
|
* @inheritdoc
|
||||||
@@ -134,7 +122,7 @@ class slack implements notification_i
|
|||||||
. "Status: $status";
|
. "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;
|
global $SLACK_DEFAULT_WEBHOOK;
|
||||||
// Format the message if a module is provided
|
// Format the message if a module is provided
|
||||||
@@ -144,189 +132,4 @@ class slack implements notification_i
|
|||||||
// Send the message to the slack webhook
|
// Send the message to the slack webhook
|
||||||
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_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.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -499,7 +499,6 @@ class system_search_document_index
|
|||||||
*/
|
*/
|
||||||
private function buildCustomerDiscountDocuments(): array
|
private function buildCustomerDiscountDocuments(): array
|
||||||
{
|
{
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
|
||||||
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
||||||
$selectFields = [
|
$selectFields = [
|
||||||
'po.id AS entity_id',
|
'po.id AS entity_id',
|
||||||
@@ -507,7 +506,6 @@ class system_search_document_index
|
|||||||
'po.is_category',
|
'po.is_category',
|
||||||
'po.product_or_category_id',
|
'po.product_or_category_id',
|
||||||
'po.percentage',
|
'po.percentage',
|
||||||
'po.fixed_price',
|
|
||||||
'u.customer_number',
|
'u.customer_number',
|
||||||
'u.display_name',
|
'u.display_name',
|
||||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||||
@@ -556,7 +554,6 @@ class system_search_document_index
|
|||||||
$row['search_text'] ?? null,
|
$row['search_text'] ?? null,
|
||||||
$row['product_or_category_id'] ?? null,
|
$row['product_or_category_id'] ?? null,
|
||||||
$row['percentage'] ?? null,
|
$row['percentage'] ?? null,
|
||||||
$row['fixed_price'] ?? null,
|
|
||||||
$row['user_id'] ?? null,
|
$row['user_id'] ?? null,
|
||||||
]),
|
]),
|
||||||
$this->toIntOrNull($row['customer_number'] ?? null),
|
$this->toIntOrNull($row['customer_number'] ?? null),
|
||||||
@@ -567,7 +564,6 @@ class system_search_document_index
|
|||||||
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
||||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||||
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
||||||
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
|
|
||||||
'economic_name' => $row['economic_name'] ?? null,
|
'economic_name' => $row['economic_name'] ?? null,
|
||||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||||
'is_category' => $row['is_category'] ?? null,
|
'is_category' => $row['is_category'] ?? null,
|
||||||
|
|||||||
@@ -1086,7 +1086,6 @@ class system_search_service
|
|||||||
|
|
||||||
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
||||||
{
|
{
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
|
||||||
$customerFilter = '';
|
$customerFilter = '';
|
||||||
if (!empty($forcedCustomerNumbers)) {
|
if (!empty($forcedCustomerNumbers)) {
|
||||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
||||||
@@ -1101,12 +1100,11 @@ class system_search_service
|
|||||||
'po.is_category',
|
'po.is_category',
|
||||||
'po.product_or_category_id',
|
'po.product_or_category_id',
|
||||||
'po.percentage',
|
'po.percentage',
|
||||||
'po.fixed_price',
|
|
||||||
'u.customer_number',
|
'u.customer_number',
|
||||||
'u.display_name',
|
'u.display_name',
|
||||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||||
];
|
];
|
||||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
|
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
|
||||||
|
|
||||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||||
@@ -1168,7 +1166,6 @@ class system_search_service
|
|||||||
'search_text',
|
'search_text',
|
||||||
'product_or_category_id',
|
'product_or_category_id',
|
||||||
'percentage',
|
'percentage',
|
||||||
'fixed_price',
|
|
||||||
'user_id',
|
'user_id',
|
||||||
], $terms) + $entityBoost,
|
], $terms) + $entityBoost,
|
||||||
'payload' => $this->augmentPayloadWithTemporal([
|
'payload' => $this->augmentPayloadWithTemporal([
|
||||||
@@ -1176,7 +1173,6 @@ class system_search_service
|
|||||||
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
||||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||||
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
||||||
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
|
|
||||||
'economic_name' => $row['economic_name'] ?? null,
|
'economic_name' => $row['economic_name'] ?? null,
|
||||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||||
], $row),
|
], $row),
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "composer test:unit",
|
"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:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
|
||||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||||
"test:api": [
|
"test:api": [
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ use classes\slack as Slack;
|
|||||||
use classes\email as Email;
|
use classes\email as Email;
|
||||||
use classes\gatewayapi as GatewayAPI;
|
use classes\gatewayapi as GatewayAPI;
|
||||||
use dynamicimages\images\machine_1;
|
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\classes\goals_criteria;
|
||||||
use goals\services\goals_progress_alert_renderer;
|
use goals\services\goals_progress_alert_renderer;
|
||||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||||
@@ -35,8 +33,6 @@ use objects\users_o;
|
|||||||
use routes\moduleWeatherAPIRoute;
|
use routes\moduleWeatherAPIRoute;
|
||||||
|
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
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_schema_bootstrap.php';
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||||
@@ -934,7 +930,6 @@ function buildDynamicImageCacheKey(array $variant): string
|
|||||||
'current_step' => (int)($variant['current_step'] ?? 0),
|
'current_step' => (int)($variant['current_step'] ?? 0),
|
||||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||||
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$json = json_encode($cacheParams);
|
$json = json_encode($cacheParams);
|
||||||
@@ -967,11 +962,16 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
|||||||
$image->current_step = max(0, $currentStep);
|
$image->current_step = max(0, $currentStep);
|
||||||
$image->only_generate_current_step = $onlyCurrentStep;
|
$image->only_generate_current_step = $onlyCurrentStep;
|
||||||
$image->setup();
|
$image->setup();
|
||||||
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
|
||||||
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $image->exportBinary('png');
|
$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;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
@@ -985,19 +985,6 @@ 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
|
* @param mixed $value
|
||||||
* @return array<int|string>
|
* @return array<int|string>
|
||||||
|
|||||||
@@ -12,35 +12,10 @@ interface licenseplaterecognizer_i extends universal_module_i
|
|||||||
* @return array An array containing the plate number and other relevant information.
|
* @return array An array containing the plate number and other relevant information.
|
||||||
*/
|
*/
|
||||||
public function licenseplaterecognizer(string $base64_image): array;
|
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.
|
* Get usage information about the license plate recognizer module.
|
||||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||||
* @see licenseplaterecognizer_info
|
* @see licenseplaterecognizer_info
|
||||||
*/
|
*/
|
||||||
public function get_usage(): licenseplaterecognizer_info;
|
public function get_usage(): licenseplaterecognizer_info;
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,22 @@ require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_c.ph
|
|||||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
|
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
|
||||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
|
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
|
||||||
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
|
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_backend_repository_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_count_per_repo_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_frontend_repository_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_labels_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_service_uuid_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_token_c.php';
|
||||||
|
|
||||||
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
|
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
|
||||||
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
|
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
|
||||||
use modules\coolify\config\coolify_enabled_c;
|
use modules\coolify\config\coolify_enabled_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_backend_repository_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_count_per_repo_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_frontend_repository_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_labels_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_service_uuid_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_token_c;
|
||||||
use modules\coolify\config\coolify_lb_automation_enabled_c;
|
use modules\coolify\config\coolify_lb_automation_enabled_c;
|
||||||
use modules\coolify\config\coolify_lb_automation_mode_c;
|
use modules\coolify\config\coolify_lb_automation_mode_c;
|
||||||
use modules\coolify\config\coolify_public_gateway_host_c;
|
use modules\coolify\config\coolify_public_gateway_host_c;
|
||||||
@@ -29,6 +41,12 @@ class coolify_c
|
|||||||
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
|
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
|
||||||
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
|
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
|
||||||
public coolify_public_gateway_host_c $public_gateway_host;
|
public coolify_public_gateway_host_c $public_gateway_host;
|
||||||
|
public coolify_github_runner_token_c $github_runner_token;
|
||||||
|
public coolify_github_runner_service_uuid_c $github_runner_service_uuid;
|
||||||
|
public coolify_github_runner_frontend_repository_c $github_runner_frontend_repository;
|
||||||
|
public coolify_github_runner_backend_repository_c $github_runner_backend_repository;
|
||||||
|
public coolify_github_runner_labels_c $github_runner_labels;
|
||||||
|
public coolify_github_runner_count_per_repo_c $github_runner_count_per_repo;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -40,6 +58,12 @@ class coolify_c
|
|||||||
coolify_hetzner_load_balancer_id_c::class,
|
coolify_hetzner_load_balancer_id_c::class,
|
||||||
coolify_hetzner_cloud_api_token_c::class,
|
coolify_hetzner_cloud_api_token_c::class,
|
||||||
coolify_public_gateway_host_c::class,
|
coolify_public_gateway_host_c::class,
|
||||||
|
coolify_github_runner_token_c::class,
|
||||||
|
coolify_github_runner_service_uuid_c::class,
|
||||||
|
coolify_github_runner_frontend_repository_c::class,
|
||||||
|
coolify_github_runner_backend_repository_c::class,
|
||||||
|
coolify_github_runner_labels_c::class,
|
||||||
|
coolify_github_runner_count_per_repo_c::class,
|
||||||
]);
|
]);
|
||||||
$this->enabled = new coolify_enabled_c();
|
$this->enabled = new coolify_enabled_c();
|
||||||
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
|
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
|
||||||
@@ -47,12 +71,18 @@ class coolify_c
|
|||||||
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
|
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
|
||||||
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
|
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
|
||||||
$this->public_gateway_host = new coolify_public_gateway_host_c();
|
$this->public_gateway_host = new coolify_public_gateway_host_c();
|
||||||
|
$this->github_runner_token = new coolify_github_runner_token_c();
|
||||||
|
$this->github_runner_service_uuid = new coolify_github_runner_service_uuid_c();
|
||||||
|
$this->github_runner_frontend_repository = new coolify_github_runner_frontend_repository_c();
|
||||||
|
$this->github_runner_backend_repository = new coolify_github_runner_backend_repository_c();
|
||||||
|
$this->github_runner_labels = new coolify_github_runner_labels_c();
|
||||||
|
$this->github_runner_count_per_repo = new coolify_github_runner_count_per_repo_c();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getConfigRequest(): array
|
public function getConfigRequest(): array
|
||||||
{
|
{
|
||||||
return array_map(static function (array $row): array {
|
return array_map(static function (array $row): array {
|
||||||
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
|
if (in_array(($row['variable'] ?? ''), ['hetzner_cloud_api_token', 'github_runner_token'], true)) {
|
||||||
$secretSet = trim((string)($row['value'] ?? '')) !== '';
|
$secretSet = trim((string)($row['value'] ?? '')) !== '';
|
||||||
$row['value'] = $secretSet ? '[redacted]' : '';
|
$row['value'] = $secretSet ? '[redacted]' : '';
|
||||||
$row['secret_set'] = $secretSet;
|
$row['secret_set'] = $secretSet;
|
||||||
|
|||||||
@@ -86,14 +86,6 @@ interface dynamicimages_image_i
|
|||||||
*/
|
*/
|
||||||
public function exportAsBase64(?string $format = null, int $quality = 90): string;
|
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.
|
* Directly serve the composed image to the client with proper headers.
|
||||||
* Convenience wrapper for outputting binary image data.
|
* Convenience wrapper for outputting binary image data.
|
||||||
@@ -102,4 +94,4 @@ interface dynamicimages_image_i
|
|||||||
* @param int $quality Quality for lossy formats (0-100)
|
* @param int $quality Quality for lossy formats (0-100)
|
||||||
*/
|
*/
|
||||||
public function servePicture(?string $format = null, int $quality = 90): void;
|
public function servePicture(?string $format = null, int $quality = 90): void;
|
||||||
}
|
}
|
||||||
@@ -226,20 +226,6 @@ trait dynamicimages_image_t
|
|||||||
return $this;
|
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
|
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
|
||||||
{
|
{
|
||||||
$this->assertCanvasInitialized();
|
$this->assertCanvasInitialized();
|
||||||
@@ -321,8 +307,20 @@ trait dynamicimages_image_t
|
|||||||
*/
|
*/
|
||||||
public function exportAsBase64(?string $format = null, int $quality = 90): string
|
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) {
|
if ($this->image instanceof \Imagick) {
|
||||||
return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: export first asset as-is
|
// Fallback: export first asset as-is
|
||||||
@@ -343,40 +341,6 @@ trait dynamicimages_image_t
|
|||||||
return 'data:' . $mime . ';base64,' . base64_encode($data);
|
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
|
public function getAsset(string $asset_name): ?dynamicimages_asset
|
||||||
{
|
{
|
||||||
foreach ($this->assets as $asset) {
|
foreach ($this->assets as $asset) {
|
||||||
@@ -393,18 +357,22 @@ trait dynamicimages_image_t
|
|||||||
*/
|
*/
|
||||||
public function outputImage(?string $format = null, int $quality = 90): void
|
public function outputImage(?string $format = null, int $quality = 90): void
|
||||||
{
|
{
|
||||||
$mimeType = 'image/png';
|
$dataUri = $this->exportAsBase64($format, $quality);
|
||||||
if (!$this->image instanceof \Imagick && !empty($this->assets)) {
|
// Extract mime type and base64 data
|
||||||
$asset = $this->assets[0];
|
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) {
|
||||||
$path = $asset->getPath();
|
$mimeType = $matches[1];
|
||||||
$imgInfo = is_readable($path) ? @getimagesize($path) : false;
|
$base64Data = $matches[2];
|
||||||
$mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$imageData = $this->exportBinary($format, $quality);
|
|
||||||
header('Content-Type: ' . $mimeType);
|
|
||||||
header('Content-Length: ' . strlen($imageData));
|
|
||||||
echo $imageData;
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,4 +382,4 @@ trait dynamicimages_image_t
|
|||||||
{
|
{
|
||||||
$this->outputImage($format, $quality);
|
$this->outputImage($format, $quality);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,8 +14,6 @@ class economic_customer_mo
|
|||||||
public null|string $message;
|
public null|string $message;
|
||||||
public null|string $corporateIdentificationNumber;
|
public null|string $corporateIdentificationNumber;
|
||||||
public null|string $email;
|
public null|string $email;
|
||||||
public null|string $ean;
|
|
||||||
public null|string $publicEntryNumber;
|
|
||||||
public null|string $mobilePhone;
|
public null|string $mobilePhone;
|
||||||
public null|string $currency;
|
public null|string $currency;
|
||||||
public null|string $country;
|
public null|string $country;
|
||||||
@@ -48,8 +46,6 @@ class economic_customer_mo
|
|||||||
$this->zip = ($customer->zip ?? null);
|
$this->zip = ($customer->zip ?? null);
|
||||||
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
|
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
|
||||||
$this->email = ($customer->email ?? null);
|
$this->email = ($customer->email ?? null);
|
||||||
$this->ean = ($customer->ean ?? null);
|
|
||||||
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
|
|
||||||
$this->mobilePhone = ($customer->mobilePhone ?? null);
|
$this->mobilePhone = ($customer->mobilePhone ?? null);
|
||||||
$this->currency = ($customer->currency ?? null);
|
$this->currency = ($customer->currency ?? null);
|
||||||
$this->country = ($customer->country ?? null);
|
$this->country = ($customer->country ?? null);
|
||||||
@@ -104,8 +100,6 @@ class economic_customer_mo
|
|||||||
'zip' => $this->zip,
|
'zip' => $this->zip,
|
||||||
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
|
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'ean' => $this->ean,
|
|
||||||
'publicEntryNumber' => $this->publicEntryNumber,
|
|
||||||
'mobilePhone' => $this->mobilePhone,
|
'mobilePhone' => $this->mobilePhone,
|
||||||
'currency' => $this->currency,
|
'currency' => $this->currency,
|
||||||
'country' => $this->country,
|
'country' => $this->country,
|
||||||
|
|||||||
+5
-33
@@ -61,47 +61,19 @@ class economic_invoices_draft_endpoint
|
|||||||
* @throws Exception If the request fails
|
* @throws Exception If the request fails
|
||||||
*/
|
*/
|
||||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
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);
|
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||||
$orders_with_invoice_lines = 0;
|
// Check if the order includes any items that should be included in the invoice
|
||||||
|
if ($order->getIncludeInInvoiceCount() > 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.)
|
// Add the transaction header (Timestamp, department, etc.)
|
||||||
$draftInvoice->addNewTransactionHeader($order);
|
$draftInvoice->addNewTransactionHeader($order);
|
||||||
// Add the order lines
|
// Add the order lines
|
||||||
$draftInvoice->addOrderItemLines($order);
|
$draftInvoice->addOrderItemLines($order);
|
||||||
// Add an empty line, so the invoice is not empty
|
// Add an empty line, so the invoice is not empty
|
||||||
$draftInvoice->addTextLine('');
|
$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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,4 +151,4 @@ class economic_invoices_draft_endpoint
|
|||||||
$draft_invoice->addLines();
|
$draft_invoice->addLines();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-19
@@ -127,23 +127,6 @@ class economic_invoices_drafts_endpoint
|
|||||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||||
$recipient = [
|
|
||||||
'name' => $customer_name,
|
|
||||||
'address' => $customer_address,
|
|
||||||
'zip' => $customer_zip,
|
|
||||||
'city' => $customer_city,
|
|
||||||
'vatZone' => [
|
|
||||||
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$customer_ean = $customer->getEan();
|
|
||||||
if ($customer_ean !== null) {
|
|
||||||
$recipient['ean'] = $customer_ean;
|
|
||||||
}
|
|
||||||
$public_entry_number = $customer->getPublicEntryNumber();
|
|
||||||
if ($public_entry_number !== null) {
|
|
||||||
$recipient['publicEntryNumber'] = $public_entry_number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
$response = $this->send_request(
|
$response = $this->send_request(
|
||||||
@@ -182,7 +165,15 @@ class economic_invoices_drafts_endpoint
|
|||||||
'currency' => $customer->getCurrency() ?? 'DKK',
|
'currency' => $customer->getCurrency() ?? 'DKK',
|
||||||
|
|
||||||
// Set the recipient details
|
// Set the recipient details
|
||||||
'recipient' => $recipient,
|
'recipient' => [
|
||||||
|
'name' => $customer_name,
|
||||||
|
'address' => $customer_address,
|
||||||
|
'zip' => $customer_zip,
|
||||||
|
'city' => $customer_city,
|
||||||
|
'vatZone' => [
|
||||||
|
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
|
||||||
|
],
|
||||||
|
],
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
// Return the response as an object
|
// Return the response as an object
|
||||||
@@ -203,4 +194,4 @@ class economic_invoices_drafts_endpoint
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -149,29 +149,6 @@ class economic_customer
|
|||||||
return $this->customer_data_object->email;
|
return $this->customer_data_object->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEan(): ?string
|
|
||||||
{
|
|
||||||
self::requireSelected();
|
|
||||||
return $this->nullableStringField('ean');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPublicEntryNumber(): ?string
|
|
||||||
{
|
|
||||||
self::requireSelected();
|
|
||||||
return $this->nullableStringField('publicEntryNumber');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function nullableStringField(string $field): ?string
|
|
||||||
{
|
|
||||||
$value = $this->customer_data_object->{$field} ?? null;
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim((string)$value);
|
|
||||||
return $normalized !== '' ? $normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the customer address
|
* Get the customer address
|
||||||
* @return string The customer address
|
* @return string The customer address
|
||||||
@@ -250,4 +227,4 @@ class economic_customer
|
|||||||
return $this->customer_data_object->vatZone->vatZoneNumber;
|
return $this->customer_data_object->vatZone->vatZoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,6 @@ use objects\orders_o;
|
|||||||
|
|
||||||
class economic_invoice_draft
|
class economic_invoice_draft
|
||||||
{
|
{
|
||||||
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Economic draftInvoiceNumber
|
* The Economic draftInvoiceNumber
|
||||||
* @var int $draft_invoice_number
|
* @var int $draft_invoice_number
|
||||||
@@ -112,55 +110,13 @@ class economic_invoice_draft
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the lines to the draft invoice.
|
* Add the lines to the draft invoice
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function addLines(): void
|
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 = new economic();
|
||||||
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-44
@@ -6,7 +6,6 @@ use Exception;
|
|||||||
|
|
||||||
class edge_gateway_agent_artifact_locator
|
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 ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||||
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||||
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||||
@@ -31,10 +30,6 @@ class edge_gateway_agent_artifact_locator
|
|||||||
|
|
||||||
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||||
|
|
||||||
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
|
|
||||||
$candidateDirectories[] = $directory;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||||
if ($mountedArtifactDirectory !== null) {
|
if ($mountedArtifactDirectory !== null) {
|
||||||
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
|
||||||
@@ -45,9 +40,9 @@ class edge_gateway_agent_artifact_locator
|
|||||||
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
|
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||||
$candidateDirectories[] = $directory;
|
$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');
|
||||||
|
|
||||||
$paths = [];
|
$paths = [];
|
||||||
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||||
@@ -107,42 +102,6 @@ class edge_gateway_agent_artifact_locator
|
|||||||
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
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
|
private static function mountedArtifactDirectory(): ?string
|
||||||
{
|
{
|
||||||
if (DIRECTORY_SEPARATOR !== '/') {
|
if (DIRECTORY_SEPARATOR !== '/') {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use Exception;
|
|||||||
class edge_gateway_install_service
|
class edge_gateway_install_service
|
||||||
{
|
{
|
||||||
private const ARTIFACTS = [
|
private const ARTIFACTS = [
|
||||||
'manifest.json' => 'application/json; charset=utf-8',
|
|
||||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
'lan-worker.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',
|
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||||
@@ -27,7 +26,7 @@ class edge_gateway_install_service
|
|||||||
|
|
||||||
public function buildInstallScript(string $plainToken): string
|
public function buildInstallScript(string $plainToken): string
|
||||||
{
|
{
|
||||||
return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
|
return $this->manager()->buildInstallScript($plainToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,17 +42,13 @@ class edge_gateway_install_service
|
|||||||
*/
|
*/
|
||||||
public function readArtifact(string $fileName): string
|
public function readArtifact(string $fileName): string
|
||||||
{
|
{
|
||||||
if ($fileName === 'manifest.json') {
|
|
||||||
return $this->buildManifest();
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = $this->artifactPath($fileName);
|
$path = $this->artifactPath($fileName);
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
if ($contents === false) {
|
if ($contents === false) {
|
||||||
throw new Exception('Unable to read edge agent artifact');
|
throw new Exception('Unable to read edge agent artifact');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->normalizeLineEndings($contents);
|
return $contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function contentType(string $fileName): string
|
public function contentType(string $fileName): string
|
||||||
@@ -71,55 +66,15 @@ class edge_gateway_install_service
|
|||||||
*/
|
*/
|
||||||
public function artifactPath(string $fileName): string
|
public function artifactPath(string $fileName): string
|
||||||
{
|
{
|
||||||
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
|
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
||||||
throw new Exception('Unknown edge agent artifact');
|
throw new Exception('Unknown edge agent artifact');
|
||||||
}
|
}
|
||||||
|
|
||||||
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
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
|
private function manager(): edge_gateway_manager
|
||||||
{
|
{
|
||||||
return $this->manager ?? new 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,7 +18,8 @@ class edge_gateway_schema_bootstrap
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = self::pdo();
|
global $db;
|
||||||
|
|
||||||
$queries = [
|
$queries = [
|
||||||
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
"CREATE TABLE IF NOT EXISTS edge_gateways (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -123,31 +124,6 @@ class edge_gateway_schema_bootstrap
|
|||||||
INDEX idx_edge_gateway_command_type (command_type)
|
INDEX idx_edge_gateway_command_type (command_type)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
) 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 (
|
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
gateway_id INT NOT NULL,
|
gateway_id INT NOT NULL,
|
||||||
@@ -257,18 +233,11 @@ class edge_gateway_schema_bootstrap
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($queries as $sql) {
|
foreach ($queries as $sql) {
|
||||||
$pdo->exec($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
|
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_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");
|
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
|
||||||
@@ -335,6 +304,8 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
if (self::tableHasColumn($table, $column)) {
|
if (self::tableHasColumn($table, $column)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -343,7 +314,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
||||||
}
|
}
|
||||||
|
|
||||||
self::pdo()->exec(
|
$db->query(
|
||||||
"ALTER TABLE `$table`
|
"ALTER TABLE `$table`
|
||||||
ADD COLUMN `$column` $definition"
|
ADD COLUMN `$column` $definition"
|
||||||
);
|
);
|
||||||
@@ -356,6 +327,8 @@ class edge_gateway_schema_bootstrap
|
|||||||
string $definition,
|
string $definition,
|
||||||
?string $afterColumn = null
|
?string $afterColumn = null
|
||||||
): void {
|
): void {
|
||||||
|
global $db;
|
||||||
|
|
||||||
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -369,7 +342,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
|
||||||
|
|
||||||
self::pdo()->exec(
|
$db->query(
|
||||||
"ALTER TABLE `$table`
|
"ALTER TABLE `$table`
|
||||||
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
CHANGE COLUMN `$from` `$to` $definition$positionClause"
|
||||||
);
|
);
|
||||||
@@ -377,35 +350,38 @@ class edge_gateway_schema_bootstrap
|
|||||||
|
|
||||||
private static function tableHasColumn(string $table, string $column): bool
|
private static function tableHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
|
global $db;
|
||||||
throw new \RuntimeException('Invalid schema bootstrap identifier');
|
|
||||||
}
|
|
||||||
|
|
||||||
$statement = self::pdo()->prepare(
|
$table = $db->escape_string($table);
|
||||||
|
$column = $db->escape_string($column);
|
||||||
|
$database = $db->escape_string($db->getDatabase());
|
||||||
|
|
||||||
|
$result = $db->query(
|
||||||
"SELECT COUNT(*) AS c
|
"SELECT COUNT(*) AS c
|
||||||
FROM information_schema.COLUMNS
|
FROM information_schema.COLUMNS
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
WHERE TABLE_SCHEMA = '$database'
|
||||||
AND TABLE_NAME = :table
|
AND TABLE_NAME = '$table'
|
||||||
AND COLUMN_NAME = :column"
|
AND COLUMN_NAME = '$column'"
|
||||||
);
|
);
|
||||||
$statement->execute([
|
|
||||||
':table' => $table,
|
|
||||||
':column' => $column,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$row = $statement->fetch();
|
if (!$result) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
return ((int)($row['c'] ?? 0)) > 0;
|
return ((int)($row['c'] ?? 0)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function syncOperationTypeColumns(): void
|
private static function syncOperationTypeColumns(): void
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|
||||||
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = self::pdo();
|
$db->query(
|
||||||
$pdo->exec(
|
|
||||||
"UPDATE edge_gateway_operations
|
"UPDATE edge_gateway_operations
|
||||||
SET type = operation_type
|
SET type = operation_type
|
||||||
WHERE operation_type IS NOT NULL
|
WHERE operation_type IS NOT NULL
|
||||||
@@ -413,7 +389,7 @@ class edge_gateway_schema_bootstrap
|
|||||||
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
AND (type IS NULL OR type = '' OR type <> operation_type)"
|
||||||
);
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$db->query(
|
||||||
"UPDATE edge_gateway_operations
|
"UPDATE edge_gateway_operations
|
||||||
SET operation_type = type
|
SET operation_type = type
|
||||||
WHERE type IS NOT NULL
|
WHERE type IS NOT NULL
|
||||||
@@ -421,88 +397,4 @@ class edge_gateway_schema_bootstrap
|
|||||||
AND (operation_type IS NULL OR operation_type = '')"
|
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,7 +81,6 @@ class edgeGatewaysRoute
|
|||||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
$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/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/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||||
@@ -99,8 +98,6 @@ class edgeGatewaysRoute
|
|||||||
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
|
$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/poll', fn() => $this->handleAgentCommandPoll());
|
||||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
$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}/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-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
|
||||||
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
|
||||||
@@ -112,7 +109,6 @@ class edgeGatewaysRoute
|
|||||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
$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}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
$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/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||||
@@ -402,11 +398,7 @@ class edgeGatewaysRoute
|
|||||||
$response->error('Missing token', 400);
|
$response->error('Missing token', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$response->success($this->install()->verifyInstallToken($token));
|
||||||
$response->success($this->install()->verifyInstallToken($token));
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$response->error($exception->getMessage(), 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentInstallTokenStatus(): void
|
private function handleAgentInstallTokenStatus(): void
|
||||||
@@ -415,21 +407,17 @@ class edgeGatewaysRoute
|
|||||||
self::requireParameters(['token', 'status']);
|
self::requireParameters(['token', 'status']);
|
||||||
|
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
try {
|
$response->success($this->registry()->reportInstallTokenStatus(
|
||||||
$response->success($this->registry()->reportInstallTokenStatus(
|
(string)$payload['token'],
|
||||||
(string)$payload['token'],
|
[
|
||||||
[
|
'status' => (string)$payload['status'],
|
||||||
'status' => (string)$payload['status'],
|
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : 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
|
private function renderArtifact(string $fileName): void
|
||||||
@@ -451,19 +439,15 @@ class edgeGatewaysRoute
|
|||||||
self::requireParameters(['token']);
|
self::requireParameters(['token']);
|
||||||
$payload = self::getParametersAsArray();
|
$payload = self::getParametersAsArray();
|
||||||
|
|
||||||
try {
|
$response->success(
|
||||||
$response->success(
|
$this->registry()->claimGateway(
|
||||||
$this->registry()->claimGateway(
|
(string)$payload['token'],
|
||||||
(string)$payload['token'],
|
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
),
|
||||||
),
|
201
|
||||||
201
|
);
|
||||||
);
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$response->error($exception->getMessage(), 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAgentHeartbeat(): void
|
private function handleAgentHeartbeat(): void
|
||||||
@@ -571,31 +555,6 @@ 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
|
private function handleAgentPresence(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
@@ -747,21 +706,6 @@ 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
|
private function handleBrokerBrowserStreamValidate(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */ $response;
|
global /** @var response $response */ $response;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
namespace email\templates;
|
namespace email\templates;
|
||||||
|
|
||||||
use email\helpers\email_template;
|
use email\helpers\email_template;use objects\users_o;
|
||||||
use objects\users_o;
|
|
||||||
|
|
||||||
class email_template_new_customer
|
class email_template_new_customer
|
||||||
{
|
{
|
||||||
@@ -53,7 +52,6 @@ class email_template_new_customer
|
|||||||
*/
|
*/
|
||||||
public function generate_html(): string
|
public function generate_html(): string
|
||||||
{
|
{
|
||||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
|
||||||
ob_start();
|
ob_start();
|
||||||
# Start of the html
|
# Start of the html
|
||||||
?>
|
?>
|
||||||
@@ -75,7 +73,7 @@ class email_template_new_customer
|
|||||||
|
|
||||||
<!-- Intro -->
|
<!-- 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;">
|
<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 <?=$customer_label?> som kunde hos Truck Wash.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- You can now wash your trucks -->
|
<!-- You can now wash your trucks -->
|
||||||
@@ -187,19 +185,4 @@ class email_template_new_customer
|
|||||||
# End of the html
|
# End of the html
|
||||||
return ob_get_clean();
|
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,7 +7,6 @@ class selfserve_lane_command_arguments
|
|||||||
public ?string $license_plate = null;
|
public ?string $license_plate = null;
|
||||||
public ?int $customer_number = null;
|
public ?int $customer_number = null;
|
||||||
public ?int $subuser_id = null;
|
public ?int $subuser_id = null;
|
||||||
public ?string $wash_mode = null;
|
|
||||||
public bool $defer_relay_side_effects = false;
|
public bool $defer_relay_side_effects = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,22 +32,6 @@ class selfserve_lane_command_arguments
|
|||||||
return $this;
|
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
|
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||||
{
|
{
|
||||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||||
@@ -67,12 +50,6 @@ class selfserve_lane_command_arguments
|
|||||||
if (array_key_exists('subuser_id', $params)) {
|
if (array_key_exists('subuser_id', $params)) {
|
||||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
$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)) {
|
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||||
$this->setDeferRelaySideEffects(filter_var(
|
$this->setDeferRelaySideEffects(filter_var(
|
||||||
$params['defer_relay_side_effects'],
|
$params['defer_relay_side_effects'],
|
||||||
|
|||||||
@@ -148,25 +148,6 @@ class selfserve_machine_signal
|
|||||||
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
||||||
$departmentId = (int)$gateway->department_id->value();
|
$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(
|
return $this->recordCloudShellySignal(
|
||||||
$departmentId,
|
$departmentId,
|
||||||
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
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 DEFAULT_PATH_MAX_STATES = 2048;
|
||||||
private const MAX_PATH_MAX_STATES = 2048;
|
private const MAX_PATH_MAX_STATES = 2048;
|
||||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
|
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
||||||
private const MAX_PATH_SAMPLE_LIMIT = 2048;
|
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
||||||
|
|
||||||
/** @var array<string,array<int,string>> */
|
/** @var array<string,array<int,string>> */
|
||||||
private array $columnCache = [];
|
private array $columnCache = [];
|
||||||
|
|||||||
@@ -103,79 +103,49 @@ 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
|
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);
|
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||||
$mutationResult = $this->withSessionMutationLock(
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||||
$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) {
|
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||||
return [
|
return $session->exists()
|
||||||
'session' => $session,
|
? $this->getSessionSummary((int)$session->id)
|
||||||
'response' => $session->exists()
|
: $this->formatBlockedSessionSummary($snapshot);
|
||||||
? $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) {
|
if ($syncRelayState) {
|
||||||
if ($session->exists()) {
|
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
@@ -360,13 +330,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
$allowedServices = $this->normalizeServiceNames(
|
$allowedServices = $this->normalizeServiceNames(
|
||||||
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
|
||||||
);
|
);
|
||||||
$machineWashEnabled = $this->isMachineWashEnabled();
|
$machineAvailable = array_key_exists('machine_available', $metadata)
|
||||||
if (!$machineWashEnabled) {
|
|
||||||
$allowedServices = $this->withoutMachineService($allowedServices);
|
|
||||||
}
|
|
||||||
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
|
|
||||||
? (bool)$metadata['machine_available']
|
? (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)
|
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
|
||||||
? (bool)$metadata['all_visible_questions_answered']
|
? (bool)$metadata['all_visible_questions_answered']
|
||||||
: true;
|
: true;
|
||||||
@@ -412,7 +378,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'events' => $events,
|
'events' => $events,
|
||||||
'allowed_services' => $allowedServices,
|
'allowed_services' => $allowedServices,
|
||||||
'machine_available' => $machineAvailable,
|
'machine_available' => $machineAvailable,
|
||||||
'machine_wash_enabled' => $machineWashEnabled,
|
|
||||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||||
'allowed' => (bool)$session->allowed->value(),
|
'allowed' => (bool)$session->allowed->value(),
|
||||||
'config_version_id' => $metadata['config_version_id'] ?? null,
|
'config_version_id' => $metadata['config_version_id'] ?? null,
|
||||||
@@ -430,7 +395,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
||||||
{
|
{
|
||||||
$session = $reg !== null
|
$session = $reg !== null
|
||||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||||
@@ -440,12 +405,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$session->markCompletedIfOpen($orderId)) {
|
$session->markCompleted($orderId);
|
||||||
return $this->getSessionSummary((int)$session->id);
|
$this->disableMachineRelayForCompletedWash($laneId);
|
||||||
}
|
|
||||||
if ($disableRelays) {
|
|
||||||
$this->disableMachineRelayForCompletedWash($laneId);
|
|
||||||
}
|
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||||
@@ -489,12 +450,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'runtime_before_reset' => $runtimeSnapshot,
|
'runtime_before_reset' => $runtimeSnapshot,
|
||||||
'forced_at' => date('Y-m-d H:i:s'),
|
'forced_at' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
|
$session->markForceStopped($orderId, $eventPayload);
|
||||||
$summary = $this->getSessionSummary((int)$session->id);
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||||
} else {
|
$summary = $this->getSessionSummary((int)$session->id);
|
||||||
$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());
|
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||||
@@ -692,12 +650,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$machineWashEnabled = $this->isMachineWashEnabled();
|
$machineAvailable = !empty($lane->relay_machine_id->value());
|
||||||
if (!$machineWashEnabled) {
|
|
||||||
$allowedServices = $this->withoutMachineService($allowedServices);
|
|
||||||
}
|
|
||||||
|
|
||||||
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
|
|
||||||
$allVisibleQuestionsAnswered = true;
|
$allVisibleQuestionsAnswered = true;
|
||||||
foreach ($visibleQuestions as $question) {
|
foreach ($visibleQuestions as $question) {
|
||||||
if ($question['answer'] === null) {
|
if ($question['answer'] === null) {
|
||||||
@@ -736,10 +689,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'tasks' => $visibleTasks,
|
'tasks' => $visibleTasks,
|
||||||
'allowed_services' => $allowedServices,
|
'allowed_services' => $allowedServices,
|
||||||
'machine_available' => $machineAvailable,
|
'machine_available' => $machineAvailable,
|
||||||
'machine_wash_enabled' => $machineWashEnabled,
|
|
||||||
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
|
||||||
'allowed' => $machineAllowed,
|
'allowed' => $machineAllowed,
|
||||||
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
|
|
||||||
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
|
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
|
||||||
'config_source' => $configSource,
|
'config_source' => $configSource,
|
||||||
'evaluation_trace' => [
|
'evaluation_trace' => [
|
||||||
@@ -838,7 +789,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'tasks' => $snapshot['tasks'],
|
'tasks' => $snapshot['tasks'],
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'machine_available' => $snapshot['machine_available'],
|
'machine_available' => $snapshot['machine_available'],
|
||||||
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
|
|
||||||
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
|
||||||
'allowed' => $snapshot['allowed'],
|
'allowed' => $snapshot['allowed'],
|
||||||
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
|
||||||
@@ -3012,7 +2962,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 array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($question['department'] ?? 0) === $departmentId
|
return (int)($question['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
|
&& (int)($question['lane'] ?? 0) === $laneId
|
||||||
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3055,7 +3005,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 array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($condition['machine_type_id'] ?? 0) === 0
|
return (int)($condition['machine_type_id'] ?? 0) === 0
|
||||||
&& (int)($condition['department'] ?? 0) === $departmentId
|
&& (int)($condition['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
|
&& (int)($condition['lane'] ?? 0) === $laneId
|
||||||
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3099,7 +3049,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 array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($task['machine_type_id'] ?? 0) === 0
|
return (int)($task['machine_type_id'] ?? 0) === 0
|
||||||
&& (int)($task['department'] ?? 0) === $departmentId
|
&& (int)($task['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
|
&& (int)($task['lane'] ?? 0) === $laneId
|
||||||
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3238,92 +3188,11 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
(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
|
protected function buildSessionMetadata(array $snapshot): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
'machine_available' => (bool)$snapshot['machine_available'],
|
'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'],
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||||
@@ -3622,27 +3491,6 @@ 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
|
protected function taskUsesMachineControls(array $task): bool
|
||||||
{
|
{
|
||||||
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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 getLatestSessionSummary(int $laneId, string $reg): array;
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||||
|
|
||||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): 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. |
|
| `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. |
|
| `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. |
|
| `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, 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. |
|
| `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. |
|
||||||
|
|
||||||
Key implementation details:
|
Key implementation details:
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
|
|
||||||
namespace modules\selfserve;
|
namespace modules\selfserve;
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
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_minute_product_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_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_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_machine_wash_minutes_included_c;
|
||||||
use modules\selfserve\config\selfserve_minute_product_c;
|
use modules\selfserve\config\selfserve_minute_product_c;
|
||||||
use traits\module_config_t;
|
use traits\module_config_t;
|
||||||
@@ -23,11 +19,6 @@ class selfserve_c
|
|||||||
* @var selfserve_enabled_c $enabled
|
* @var selfserve_enabled_c $enabled
|
||||||
*/
|
*/
|
||||||
public 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
|
* The product ID used for minute-based self-serve billing
|
||||||
* @var selfserve_minute_product_c $minute_product
|
* @var selfserve_minute_product_c $minute_product
|
||||||
@@ -38,26 +29,17 @@ class selfserve_c
|
|||||||
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
|
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
|
||||||
*/
|
*/
|
||||||
public 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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->setupConfig('selfserve');
|
$this->setupConfig('selfserve');
|
||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
selfserve_enabled_c::class,
|
selfserve_enabled_c::class,
|
||||||
selfserve_machine_wash_enabled_c::class,
|
|
||||||
selfserve_minute_product_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->enabled = new selfserve_enabled_c();
|
||||||
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
|
|
||||||
$this->minute_product = new selfserve_minute_product_c();
|
$this->minute_product = new selfserve_minute_product_c();
|
||||||
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
||||||
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,12 +171,13 @@ trait selfserve_lane_command_t
|
|||||||
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
* 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.
|
* If machine relay is not configured, this is a no-op.
|
||||||
*/
|
*/
|
||||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
protected function setMachineRelayStatusForWashStart(): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
$active_wash = new selfserve_wash_flow();
|
||||||
|
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
|
||||||
try {
|
try {
|
||||||
$this->setMachineRelayStatusHard(true);
|
$this->setMachineRelayStatusHard(true);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -192,116 +193,6 @@ 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
|
protected function openEntrancePortForWashStart(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -383,26 +274,30 @@ trait selfserve_lane_command_t
|
|||||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||||
{
|
{
|
||||||
if ($arguments->defer_relay_side_effects) {
|
if ($arguments->defer_relay_side_effects) {
|
||||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure cleaner relay is enabled whenever wash starts.
|
||||||
$this->turnOnCleanerRelayForWashStart();
|
$this->turnOnCleanerRelayForWashStart();
|
||||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
// Ensure the machine relay is ON when a wash starts, when it is allowed.
|
||||||
$this->setMachineRelayStatusForWashStart($arguments);
|
$this->setMachineRelayStatusForWashStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
|
protected function resolveSelfServeActionWashModeForStart(): string
|
||||||
{
|
{
|
||||||
if ($arguments !== null && in_array($arguments->wash_mode, [
|
try {
|
||||||
selfserve_studio_actions::MODE_MANUAL,
|
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
||||||
selfserve_studio_actions::MODE_MACHINE,
|
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
||||||
], true)) {
|
if (is_array($services)) {
|
||||||
return $arguments->wash_mode;
|
foreach ($services as $service) {
|
||||||
}
|
if (strtoupper((string)$service) === 'MACHINE') {
|
||||||
|
return selfserve_studio_actions::MODE_MACHINE;
|
||||||
if ($this->isMachineServiceSelectedForWashStart()) {
|
}
|
||||||
return selfserve_studio_actions::MODE_MACHINE;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Fall through to manual mode when the cached service set is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
return selfserve_studio_actions::MODE_MANUAL;
|
return selfserve_studio_actions::MODE_MANUAL;
|
||||||
@@ -487,16 +382,14 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable relays before opening exit gates in deterministic order:
|
* Disable relays after STOP in deterministic order:
|
||||||
* 1. Cleaner relay
|
* 1. Cleaner relay
|
||||||
* 2. Program picker relay
|
* 2. Machine relay
|
||||||
* 3. Machine relay
|
|
||||||
*/
|
*/
|
||||||
protected function turnOffRelaysAfterStop(): void
|
protected function turnOffRelaysAfterStop(): void
|
||||||
{
|
{
|
||||||
$relays = [
|
$relays = [
|
||||||
selfserve_lane_relay::MACHINE_CLEANER,
|
selfserve_lane_relay::MACHINE_CLEANER,
|
||||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -538,8 +431,7 @@ trait selfserve_lane_command_t
|
|||||||
$this->id,
|
$this->id,
|
||||||
$this->getLicensePlate() ?: null,
|
$this->getLicensePlate() ?: null,
|
||||||
$this->getCustomerNumber() ?: null,
|
$this->getCustomerNumber() ?: null,
|
||||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||||
false
|
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Session completion must not block STOP flow.
|
// Session completion must not block STOP flow.
|
||||||
@@ -671,17 +563,8 @@ trait selfserve_lane_command_t
|
|||||||
// Set the customer number and license plate
|
// Set the customer number and license plate
|
||||||
$this->setCustomerNumber($customer_number);
|
$this->setCustomerNumber($customer_number);
|
||||||
$this->setLicensePlate($license_plate);
|
$this->setLicensePlate($license_plate);
|
||||||
// Mark the lane occupied before any physical relay or gate side effects.
|
// Mark the lane occupied before any physical entrance relay side effects.
|
||||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
$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 {
|
try {
|
||||||
// Gateway timeouts are ambiguous because the relay may already have received
|
// Gateway timeouts are ambiguous because the relay may already have received
|
||||||
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
||||||
@@ -697,6 +580,15 @@ trait selfserve_lane_command_t
|
|||||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||||
// Start the wash timer
|
// Start the wash timer
|
||||||
$this->setWashStartTime(time());
|
$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
|
// Log the lane start event
|
||||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -712,8 +604,6 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
// Snapshot the physical machine ON signal before session completion/reset.
|
// Snapshot the physical machine ON signal before session completion/reset.
|
||||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||||
// Turn off relays before any configured or default exit gate opens.
|
|
||||||
$this->turnOffRelaysAfterStop();
|
|
||||||
$this->runPublishedStudioActions(
|
$this->runPublishedStudioActions(
|
||||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||||
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
||||||
@@ -726,6 +616,8 @@ trait selfserve_lane_command_t
|
|||||||
// Open the exit port. Gateway timeouts are ambiguous because
|
// Open the exit port. Gateway timeouts are ambiguous because
|
||||||
// the relay may already have received the pulse.
|
// the relay may already have received the pulse.
|
||||||
$this->openExitPortForWashStop();
|
$this->openExitPortForWashStop();
|
||||||
|
// Turn off relays in deterministic order after STOP
|
||||||
|
$this->turnOffRelaysAfterStop();
|
||||||
// Log the lane stop event
|
// Log the lane stop event
|
||||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||||
// Invoice the customer
|
// Invoice the customer
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use modules\selfserve\helpers\selfserve_lane_relay;
|
|||||||
use modules\selfserve\helpers\selfserve_lane_services;
|
use modules\selfserve\helpers\selfserve_lane_services;
|
||||||
use modules\selfserve\helpers\selfserve_lane_status;
|
use modules\selfserve\helpers\selfserve_lane_status;
|
||||||
use modules\shelly\helpers\shelly_request_body_get_states;
|
use modules\shelly\helpers\shelly_request_body_get_states;
|
||||||
use objects\selfserve_wash_sessions_o;
|
|
||||||
|
|
||||||
trait selfserve_lane_relay_controller_t
|
trait selfserve_lane_relay_controller_t
|
||||||
{
|
{
|
||||||
@@ -918,38 +917,7 @@ trait selfserve_lane_relay_controller_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->sendRelaySwitchCommand($relay, true, $duration);
|
return $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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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
@@ -1,29 +0,0 @@
|
|||||||
<?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,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?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,7 +37,6 @@ class collected_order_invoices_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $closed_at;
|
public object_property $closed_at;
|
||||||
public int $economic_wash_subscription_user_id = 1857;
|
public int $economic_wash_subscription_user_id = 1857;
|
||||||
private ?array $last_economic_transfer_metrics = null;
|
|
||||||
/**
|
/**
|
||||||
* The processor types
|
* The processor types
|
||||||
*
|
*
|
||||||
@@ -607,76 +606,6 @@ class collected_order_invoices_o extends db
|
|||||||
self::deleteCached('asArray', $this->id);
|
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
|
* Get the external id of the invoice collection
|
||||||
* @throws Exception If the request was not successful
|
* @throws Exception If the request was not successful
|
||||||
@@ -713,7 +642,6 @@ class collected_order_invoices_o extends db
|
|||||||
*/
|
*/
|
||||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||||
{
|
{
|
||||||
$this->last_economic_transfer_metrics = null;
|
|
||||||
// Require the invoice collection to be selected
|
// Require the invoice collection to be selected
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
// Require the invoice collection to be open
|
// Require the invoice collection to be open
|
||||||
@@ -738,20 +666,10 @@ class collected_order_invoices_o extends db
|
|||||||
usort($orders, function ($a, $b) {
|
usort($orders, function ($a, $b) {
|
||||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||||
});
|
});
|
||||||
// Add the invoice lines to the draft in one accumulated batch path.
|
// Add the invoices to the invoice draft
|
||||||
$order_objects = [];
|
|
||||||
foreach ( $orders as $order ) {
|
foreach ( $orders as $order ) {
|
||||||
$order_object = new orders_o();
|
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
|
||||||
$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
|
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
||||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||||
// Object changed
|
// Object changed
|
||||||
@@ -760,11 +678,6 @@ class collected_order_invoices_o extends db
|
|||||||
return $this;
|
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
|
* 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
|
* @param int $draft_id The invoice draft id
|
||||||
|
|||||||
@@ -18,18 +18,13 @@ class customer_password_reset_keys_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const TOKEN_LENGTH = 32;
|
const TOKEN_LENGTH = 32;
|
||||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
$this->setTable('customer_password_reset_keys');
|
$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
|
* Add a new customer reset key
|
||||||
@@ -70,8 +65,9 @@ class customer_password_reset_keys_o extends db
|
|||||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Query the database for a valid token using the same clock that writes created_at.
|
// Query the database for a valid token
|
||||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
$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";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
if ($result->num_rows === 0) {
|
if ($result->num_rows === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,11 +85,13 @@ class customer_password_reset_keys_o extends db
|
|||||||
*/
|
*/
|
||||||
public function isValidToken(): bool
|
public function isValidToken(): bool
|
||||||
{
|
{
|
||||||
global $db;
|
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
$created_at = strtotime($this->created_at->value());
|
||||||
$result = $db->query($sql);
|
$current_time = time();
|
||||||
return $result->num_rows > 0;
|
return (
|
||||||
|
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||||
|
($this->deleted_at->value() === null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,4 +140,4 @@ class customer_password_reset_keys_o extends db
|
|||||||
{
|
{
|
||||||
//TODO: Add cache invalidation
|
//TODO: Add cache invalidation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ use classes\object_property;
|
|||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
use classes\selfserve_schema_bootstrap;
|
use classes\selfserve_schema_bootstrap;
|
||||||
use Exception;
|
use Exception;
|
||||||
use modules\selfserve\classes\selfserve_config_versioning;
|
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
|
|
||||||
class department_lanes_o extends db
|
class department_lanes_o extends db
|
||||||
@@ -325,52 +324,9 @@ class department_lanes_o extends db
|
|||||||
public function getSelfServeLaneProducts(): array
|
public function getSelfServeLaneProducts(): array
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
$publishedProducts = $this->getPublishedSelfServeLaneProducts();
|
|
||||||
if ($publishedProducts !== []) {
|
|
||||||
return $publishedProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return department_selfserve_tasks_o::getLaneProducts((int)$this->id);
|
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
|
* @throws Exception
|
||||||
* @return department_lanes_o[] An array of department lane objects for the specified department
|
* @return department_lanes_o[] An array of department lane objects for the specified department
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class departments_o extends db
|
|||||||
public object_property $dimension; // The dimension of the department
|
public object_property $dimension; // The dimension of the department
|
||||||
public object_property $visible; // The visibility of the department
|
public object_property $visible; // The visibility of the department
|
||||||
public object_property $archived; // Whether the department is archived
|
public object_property $archived; // Whether the department is archived
|
||||||
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
|
|
||||||
public object_property $branding; // The branding of the department
|
public object_property $branding; // The branding of the department
|
||||||
public object_property $longitude; // The longitude of the department (Can be null)
|
public object_property $longitude; // The longitude of the department (Can be null)
|
||||||
public object_property $latitude; // The latitude of the department (Can be null)
|
public object_property $latitude; // The latitude of the department (Can be null)
|
||||||
@@ -108,7 +107,6 @@ class departments_o extends db
|
|||||||
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
|
||||||
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
|
||||||
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
|
||||||
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
|
|
||||||
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
|
||||||
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
|
||||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||||
@@ -187,12 +185,6 @@ class departments_o extends db
|
|||||||
return $department;
|
return $department;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isCustomPricingOnly(int $department_id): bool
|
|
||||||
{
|
|
||||||
$department = $this->getDepartmentById($department_id);
|
|
||||||
return (bool)(int)($department['custom_pricing_only'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the price of a product in a department
|
* Get the price of a product in a department
|
||||||
* @param int $department_id
|
* @param int $department_id
|
||||||
@@ -726,7 +718,7 @@ class departments_o extends db
|
|||||||
* @return null|array
|
* @return null|array
|
||||||
* @throws Exception
|
* @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,
|
25,
|
||||||
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
[23, 24], // Used to merge two products into one percentage (Spot Free)
|
||||||
22,
|
22,
|
||||||
@@ -735,11 +727,6 @@ class departments_o extends db
|
|||||||
26
|
26
|
||||||
], bool $return_as_array = false): null|array
|
], 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)) {
|
if (empty($department_ids)) {
|
||||||
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
throw new Exception('No department ids provided for the Slack internal statistic notification.');
|
||||||
}
|
}
|
||||||
@@ -860,11 +847,9 @@ class departments_o extends db
|
|||||||
$array_of_results['daily_management'][] = $tmp;
|
$array_of_results['daily_management'][] = $tmp;
|
||||||
|
|
||||||
// Send the message to the internal Slack webhook
|
// Send the message to the internal Slack webhook
|
||||||
|
$slack = new slack();
|
||||||
if (!$return_as_array) {
|
if (!$return_as_array) {
|
||||||
$webhook = $slack->get_internal_department_goal_progress_webhook_url();
|
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
|
||||||
if ($webhook !== '') {
|
|
||||||
$slack->send_webhook_message($tmp, $webhook);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Send a department-specific message for each internal department with the percentage of addons sold
|
// Send a department-specific message for each internal department with the percentage of addons sold
|
||||||
foreach ( $department_ids as $department_id ) {
|
foreach ( $department_ids as $department_id ) {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\customer_order_product_policy;
|
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
@@ -94,7 +93,6 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
global $db, $response;
|
global $db, $response;
|
||||||
try {
|
try {
|
||||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
|
||||||
// Avoid SQL injection
|
// Avoid SQL injection
|
||||||
$reference = $db->escape_string($reference);
|
$reference = $db->escape_string($reference);
|
||||||
$notes = $db->escape_string($notes);
|
$notes = $db->escape_string($notes);
|
||||||
@@ -169,20 +167,18 @@ class order_items_o extends db
|
|||||||
try {
|
try {
|
||||||
// Get the order
|
// Get the order
|
||||||
$order = (new orders_o())->getOrderById($order_id);
|
$order = (new orders_o())->getOrderById($order_id);
|
||||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
|
||||||
// Get the product price
|
// Get the product price
|
||||||
$product = (new products_o())->getProductById($product_id);
|
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
|
||||||
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
|
||||||
$price = $priceResolution['price'];
|
|
||||||
|
|
||||||
// Check if the user has a discount on the product, or category
|
// Check if the user has a discount on the product, or category
|
||||||
$customer = (new orders_o())->getOrderCustomer($order_id);
|
$customer = (new orders_o())->getOrderCustomer($order_id);
|
||||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
$discount = $customer->getCustomPrice($product_id, false);
|
||||||
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
|
if ($discount) {
|
||||||
|
$price = $price - ($price * $discount / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the price is forced, set the price to the forced price
|
// If the price is forced, set the price to the forced price
|
||||||
if ($forcePrice !== null) {
|
if ($forcePrice) {
|
||||||
$price = (int)$forcePrice;
|
$price = (int)$forcePrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,4 +354,4 @@ class order_items_o extends db
|
|||||||
{
|
{
|
||||||
return (new products_o())->select((int)$this->product_id->value());
|
return (new products_o())->select((int)$this->product_id->value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1428,16 +1428,15 @@ class orders_o extends db
|
|||||||
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
|
||||||
$order_item->reference->set('');
|
$order_item->reference->set('');
|
||||||
// Get the product price based on the department
|
// Get the product price based on the department
|
||||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||||
$product_price = (int)$priceResolution['price']; // Get the department price for the product
|
|
||||||
// Get the customers custom price discount percentage
|
// Get the customers custom price discount percentage
|
||||||
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
|
||||||
if (!$user->exists()) {
|
if (!$user->exists()) {
|
||||||
throw new Exception('No user found matching the customer number in the usage log');
|
throw new Exception('No user found matching the customer number in the usage log');
|
||||||
}
|
}
|
||||||
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
|
||||||
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
|
// Apply the discount percentage to the product price
|
||||||
}
|
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
|
||||||
$order_item->notes->set(null); // Set notes for the simulated order item
|
$order_item->notes->set(null); // Set notes for the simulated order item
|
||||||
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
|
||||||
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
|
||||||
@@ -1504,12 +1503,11 @@ class orders_o extends db
|
|||||||
if (!$current_user->exists()) {
|
if (!$current_user->exists()) {
|
||||||
throw new Exception('No current user found');
|
throw new Exception('No current user found');
|
||||||
}
|
}
|
||||||
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
|
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
|
||||||
$price = (int)$priceResolution['price']; // Get the department price for the product
|
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
|
||||||
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
// Apply the discount percentage to the product price
|
||||||
return $price;
|
// Apply the discount percentage to the product price
|
||||||
}
|
return (int)round($price * (1 - ($discount_percentage / 100)));
|
||||||
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1591,16 +1589,13 @@ class orders_o extends db
|
|||||||
$product_id = (int)$item['product_id'];
|
$product_id = (int)$item['product_id'];
|
||||||
if (!isset($department_price_cache[$product_id])) {
|
if (!isset($department_price_cache[$product_id])) {
|
||||||
$product = (new products_o())->select($product_id);
|
$product = (new products_o())->select($product_id);
|
||||||
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
|
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
|
||||||
}
|
}
|
||||||
if ($tmp_user === null) {
|
if ($tmp_user === null) {
|
||||||
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||||
}
|
}
|
||||||
$unitPrice = (int)$department_price_cache[$product_id]['price'];
|
$discount = $tmp_user->getCustomPrice($product_id, false);
|
||||||
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
|
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
|
||||||
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
|
|
||||||
}
|
|
||||||
$post_discount = $unitPrice * $quantity;
|
|
||||||
$total += $post_discount;
|
$total += $post_discount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ class products_o extends db
|
|||||||
|
|
||||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||||
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
|
|
||||||
public const PRICE_SOURCE_DEPARTMENT = 'department';
|
|
||||||
public const PRICE_SOURCE_DEFAULT = 'default';
|
|
||||||
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
|
|
||||||
public const PRICE_SOURCE_KEY = '_department_price_source';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the product
|
* The name of the product
|
||||||
@@ -260,41 +255,24 @@ class products_o extends db
|
|||||||
* @param int $department_id
|
* @param int $department_id
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
|
public function applyDepartmentPricing(array $products, int $department_id): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$department_id = $db->escape_string($department_id);
|
$department_id = $db->escape_string($department_id);
|
||||||
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
$prices = $db->fetch_all($result);
|
$prices = $db->fetch_all($result);
|
||||||
$priceLookup = [];
|
|
||||||
foreach ($prices as $price) {
|
|
||||||
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
|
|
||||||
foreach ( $products as $key => $product ) {
|
foreach ( $products as $key => $product ) {
|
||||||
$productId = (int)($product['id'] ?? 0);
|
foreach ( $prices as $price ) {
|
||||||
$source = self::PRICE_SOURCE_DEFAULT;
|
if ((int)$product['id'] === (int)$price['product_id']) {
|
||||||
if (array_key_exists($productId, $priceLookup)) {
|
$products[$key]['price'] = $price['price'];
|
||||||
$products[$key]['price'] = $priceLookup[$productId];
|
}
|
||||||
$source = self::PRICE_SOURCE_DEPARTMENT;
|
|
||||||
} elseif ($customPricingOnly) {
|
|
||||||
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
|
|
||||||
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($includePriceSource) {
|
|
||||||
$products[$key][self::PRICE_SOURCE_KEY] = $source;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getDepartmentPrice(int $department_id): int
|
||||||
* @return array{price:int,source:string}
|
|
||||||
*/
|
|
||||||
public function getDepartmentPriceResolution(int $department_id): array
|
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$department_id = $db->escape_string($department_id);
|
$department_id = $db->escape_string($department_id);
|
||||||
@@ -303,27 +281,10 @@ class products_o extends db
|
|||||||
$prices = $db->fetch_all($result);
|
$prices = $db->fetch_all($result);
|
||||||
// Check if the product has a department price
|
// Check if the product has a department price
|
||||||
if (count($prices) > 0) {
|
if (count($prices) > 0) {
|
||||||
return [
|
return $prices[0]['price'];
|
||||||
'price' => (int)$prices[0]['price'],
|
|
||||||
'source' => self::PRICE_SOURCE_DEPARTMENT,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
|
|
||||||
return [
|
|
||||||
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
|
|
||||||
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
// Return the default price
|
// Return the default price
|
||||||
return [
|
return $this->price->value();
|
||||||
'price' => (int)$this->price->value(),
|
|
||||||
'source' => self::PRICE_SOURCE_DEFAULT,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDepartmentPrice(int $department_id): int
|
|
||||||
{
|
|
||||||
return $this->getDepartmentPriceResolution($department_id)['price'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
public function applyCustomerDiscounts(array $products, users_o $customer): array
|
||||||
@@ -339,26 +300,15 @@ class products_o extends db
|
|||||||
if (!isset($product['id']) || !isset($product['price'])) {
|
if (!isset($product['id']) || !isset($product['price'])) {
|
||||||
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
|
||||||
}
|
}
|
||||||
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
|
// Get the customer's discount percentage
|
||||||
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
|
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
|
||||||
|
// Apply the discount to the product price
|
||||||
|
if ($discount_percentage > 0) {
|
||||||
|
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
|
||||||
}
|
}
|
||||||
unset($product[self::PRICE_SOURCE_KEY]);
|
|
||||||
return $product;
|
return $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function stripDepartmentPriceSources(array $products): array
|
|
||||||
{
|
|
||||||
return array_map(static function (array $product): array {
|
|
||||||
unset($product[self::PRICE_SOURCE_KEY]);
|
|
||||||
return $product;
|
|
||||||
}, $products);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function priceResolutionIsCustomMissing(array $resolution): bool
|
|
||||||
{
|
|
||||||
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSubscriptionMonthlyPrice(): int
|
public function getSubscriptionMonthlyPrice(): int
|
||||||
{
|
{
|
||||||
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
// Subscription price (for 2 washes per month) is 1.2 times the normal price
|
||||||
|
|||||||
@@ -158,18 +158,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function markCompleted(?int $orderId = null): void
|
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'));
|
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||||
if ($orderId !== null) {
|
if ($orderId !== null) {
|
||||||
@@ -179,18 +167,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
|
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'));
|
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||||
if ($orderId !== null) {
|
if ($orderId !== null) {
|
||||||
@@ -205,53 +181,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
|
$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
|
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||||
{
|
{
|
||||||
$filters = [
|
$filters = [
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
|
|||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const defaultPermissions = [
|
const defaultPermissions = [
|
||||||
'VEHICLES_LIST',
|
'VEHICLES_LIST',
|
||||||
'SELFSERVE_LIST',
|
|
||||||
'SELFSERVE_ADD',
|
'SELFSERVE_ADD',
|
||||||
'BOOKINGS_LIST',
|
'BOOKINGS_LIST',
|
||||||
'BOOKINGS_ADD',
|
'BOOKINGS_ADD',
|
||||||
'ORDERS_LIST',
|
'BOOKINGS_EDIT',
|
||||||
|
'BOOKINGS_DELETE',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function normalizePermissionsValue(mixed $raw): array
|
public static function normalizePermissionsValue(mixed $raw): array
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ class subusers_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error($e->getMessage());
|
$response->error($e->getMessage());
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,16 +217,9 @@ class subusers_o extends db
|
|||||||
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
||||||
}
|
}
|
||||||
$cache_key = 'setup_token:' . $token;
|
$cache_key = 'setup_token:' . $token;
|
||||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
$cashe_object_id = 'subuser_setup_token';
|
||||||
$cache_object_id = 'subuser_setup_token';
|
$this->cache($cache_key, $this->id, $cashe_object_id);
|
||||||
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
|
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
|
||||||
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;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,16 +260,8 @@ class subusers_o extends db
|
|||||||
public function invalidateSetupToken(string $token): void
|
public function invalidateSetupToken(string $token): void
|
||||||
{
|
{
|
||||||
$object_id = 'subuser_setup_token';
|
$object_id = 'subuser_setup_token';
|
||||||
$subuser_id = $this->getSubuserIdBySetupToken($token);
|
|
||||||
$cache_key = 'setup_token:' . $token;
|
$cache_key = 'setup_token:' . $token;
|
||||||
$this->deleteCached($cache_key, $object_id);
|
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ namespace objects;
|
|||||||
|
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use classes\price_overrides_schema_bootstrap;
|
|
||||||
use classes\system_search_cache;
|
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
|
|
||||||
class user_price_overrides_o extends db
|
class user_price_overrides_o extends db
|
||||||
@@ -16,12 +14,10 @@ class user_price_overrides_o extends db
|
|||||||
public object_property $is_category;
|
public object_property $is_category;
|
||||||
public object_property $product_or_category_id;
|
public object_property $product_or_category_id;
|
||||||
public object_property $percentage;
|
public object_property $percentage;
|
||||||
public object_property $fixed_price;
|
|
||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
$this->setTable('price_overrides');
|
$this->setTable('price_overrides');
|
||||||
price_overrides_schema_bootstrap::ensureColumns();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function objectChanged(): void
|
public function objectChanged(): void
|
||||||
@@ -34,7 +30,6 @@ class user_price_overrides_o extends db
|
|||||||
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
|
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
|
||||||
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
|
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
|
||||||
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
|
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
|
||||||
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setUser($user_id): user_price_overrides_o
|
public function setUser($user_id): user_price_overrides_o
|
||||||
@@ -48,41 +43,39 @@ class user_price_overrides_o extends db
|
|||||||
* @param bool $is_category
|
* @param bool $is_category
|
||||||
* @param int|string $product_or_category_id
|
* @param int|string $product_or_category_id
|
||||||
* @param int $percentage
|
* @param int $percentage
|
||||||
* @param int|null $fixed_price
|
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
|
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
// If the user is not set, return the object
|
// If the user is not set, return the object
|
||||||
if (!isset($this->user_id)) {
|
if (!isset($this->user_id)) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
if ($is_category) {
|
|
||||||
$fixed_price = null;
|
|
||||||
}
|
|
||||||
// Check if the record already exists
|
// Check if the record already exists
|
||||||
$this->removePriceIfExist($is_category, $product_or_category_id);
|
$this->removePriceIfExist($is_category, $product_or_category_id);
|
||||||
// If neither a discount nor a fixed product price is set, remove the record.
|
// If the percentage is 0, return the object
|
||||||
if ($percentage === 0 && $fixed_price === null) {
|
if ($percentage === 0) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
// Create a new record in the database
|
// Create a new record in the database
|
||||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)";
|
||||||
$fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price);
|
|
||||||
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)";
|
|
||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
$this->markSearchDirty();
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
|
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
// Get the price override from the database
|
||||||
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||||
$db->query($sql);
|
$result = $db->query($sql);
|
||||||
$this->markSearchDirty();
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
// Remove the record from the database
|
||||||
|
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
|
||||||
|
$db->query($sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,49 +132,6 @@ class user_price_overrides_o extends db
|
|||||||
return $percentage;
|
return $percentage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
|
|
||||||
{
|
|
||||||
if ($is_category || !isset($this->user_id)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
|
|
||||||
if ($row === null || $row['fixed_price'] === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)$row['fixed_price'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (!isset($this->user_id)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
|
|
||||||
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if (!$result || $result->num_rows < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
$row['id'] = (int)$row['id'];
|
|
||||||
$row['user_id'] = (int)$row['user_id'];
|
|
||||||
$row['is_category'] = (bool)$row['is_category'];
|
|
||||||
$row['product_or_category_id'] = $is_category
|
|
||||||
? (string)$row['product_or_category_id']
|
|
||||||
: (int)$row['product_or_category_id'];
|
|
||||||
$row['percentage'] = (int)$row['percentage'];
|
|
||||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
|
|
||||||
? (int)$row['fixed_price']
|
|
||||||
: null;
|
|
||||||
return $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the price overrides for the user
|
* Get all the price overrides for the user
|
||||||
* @return array
|
* @return array
|
||||||
@@ -203,7 +153,6 @@ class user_price_overrides_o extends db
|
|||||||
$row['is_category'] = (bool)$row['is_category'];
|
$row['is_category'] = (bool)$row['is_category'];
|
||||||
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
|
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
|
||||||
$row['percentage'] = (int)$row['percentage'];
|
$row['percentage'] = (int)$row['percentage'];
|
||||||
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
|
|
||||||
$row['created_at'] = (string)$row['created_at'];
|
$row['created_at'] = (string)$row['created_at'];
|
||||||
$row['updated_at'] = (string)$row['updated_at'];
|
$row['updated_at'] = (string)$row['updated_at'];
|
||||||
// Add the row to the list
|
// Add the row to the list
|
||||||
@@ -218,19 +167,10 @@ class user_price_overrides_o extends db
|
|||||||
'is_category' => true,
|
'is_category' => true,
|
||||||
'product_or_category_id' => "global",
|
'product_or_category_id' => "global",
|
||||||
'percentage' => (int)$economic_user_global_discount,
|
'percentage' => (int)$economic_user_global_discount,
|
||||||
'fixed_price' => null,
|
|
||||||
'created_at' => "2021-01-01 00:00:00",
|
'created_at' => "2021-01-01 00:00:00",
|
||||||
'updated_at' => "2021-01-01 00:00:00"
|
'updated_at' => "2021-01-01 00:00:00"
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $prices;
|
return $prices;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private function markSearchDirty(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
system_search_cache::markDirtyTable($this->table);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user