Merge origin/master and resolve replication route conflict

This commit is contained in:
copilot-swe-agent[bot]
2026-05-28 17:29:12 +00:00
committed by GitHub
177 changed files with 46016 additions and 3627 deletions
+3
View File
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
# Any blank debug value falls back to the live value above.
CONFIG_DB_DEBUG_HOST=mysql-debug
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+48
View File
@@ -0,0 +1,48 @@
USE_ENV=true
DEBUG=true
ENCRYPTION_KEY=ci-test-encryption-key
CORS=*
CONFIG_TIMEZONE=Europe/Copenhagen
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=root
CONFIG_DB_PASSWORD=debug_root_password
CONFIG_DB_DATABASE=nnks_db_debug
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=root
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
CONFIG_DB_DEBUG_PORT=3306
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
REDIS_CONFIG_HOST=redis
REDIS_CONFIG_USER=default
REDIS_CONFIG_DATABASE=0
REDIS_CONFIG_PASSWORD=
REDIS_CONFIG_PORT=6379
REDIS_CONFIG_DEBUG_HOST=redis
REDIS_CONFIG_DEBUG_USER=default
REDIS_CONFIG_DEBUG_DATABASE=0
REDIS_CONFIG_DEBUG_PASSWORD=
REDIS_CONFIG_DEBUG_PORT=6379
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
WORDPRESS_STATIC_TOKEN=ci-test
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
WORDPRESS_API_URL=http://localhost
MINIO_ENDPOINT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
SLACK_DEFAULT_WEBHOOK=
EDGE_BROKER_URL=http://edge-broker:4300
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
EDGE_AUTH_MODE=manager
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
EDGE_GATEWAY_VIEW_CACHE_TTL=0
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
+48
View File
@@ -0,0 +1,48 @@
USE_ENV=true
DEBUG=true
ENCRYPTION_KEY=ci-test-encryption-key
CORS=*
CONFIG_TIMEZONE=Europe/Copenhagen
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=root
CONFIG_DB_PASSWORD=debug_root_password
CONFIG_DB_DATABASE=nnks_db_debug
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=root
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
CONFIG_DB_DEBUG_PORT=3306
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
REDIS_CONFIG_HOST=redis
REDIS_CONFIG_USER=default
REDIS_CONFIG_DATABASE=0
REDIS_CONFIG_PASSWORD=
REDIS_CONFIG_PORT=6379
REDIS_CONFIG_DEBUG_HOST=redis
REDIS_CONFIG_DEBUG_USER=default
REDIS_CONFIG_DEBUG_DATABASE=0
REDIS_CONFIG_DEBUG_PASSWORD=
REDIS_CONFIG_DEBUG_PORT=6379
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
WORDPRESS_STATIC_TOKEN=ci-test
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
WORDPRESS_API_URL=http://localhost
MINIO_ENDPOINT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
SLACK_DEFAULT_WEBHOOK=
EDGE_BROKER_URL=http://edge-broker:4300
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
EDGE_AUTH_MODE=manager
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
EDGE_GATEWAY_VIEW_CACHE_TTL=0
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
+23
View File
@@ -7,6 +7,7 @@ services:
mysql-debug:
container_name: "${COMPOSE_PROJECT_NAME:-api}-mysql-debug"
ports: !reset []
edge-broker:
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
@@ -19,6 +20,7 @@ services:
caddy:
container_name: "${COMPOSE_PROJECT_NAME:-api}-caddy"
depends_on: !reset []
labels:
- "traefik.http.routers.local-api-ci.rule=PathPrefix(`/api`)"
- "traefik.http.routers.local-api-ci.entrypoints=web"
@@ -30,8 +32,29 @@ services:
php1:
container_name: "${COMPOSE_PROJECT_NAME:-api}-php1"
depends_on: !reset []
environment:
AUTO_COMPOSER_INSTALL: "false"
USE_ENV: "true"
CONFIG_DB_TARGET: "debug"
CONFIG_DB_HOST: "mysql-debug"
CONFIG_DB_USER: "root"
CONFIG_DB_PASSWORD: "debug_root_password"
CONFIG_DB_DATABASE: "nnks_db_debug"
CONFIG_DB_PORT: "3306"
CONFIG_DB_DEBUG_HOST: "mysql-debug"
CONFIG_DB_DEBUG_USER: "root"
CONFIG_DB_DEBUG_PASSWORD: "debug_root_password"
CONFIG_DB_DEBUG_DATABASE: "nnks_db_debug"
CONFIG_DB_DEBUG_PORT: "3306"
REDIS_CONFIG_HOST: "redis"
REDIS_CONFIG_PORT: "6379"
REDIS_CONFIG_DATABASE: "0"
REDIS_CONFIG_DEBUG_HOST: "redis"
REDIS_CONFIG_DEBUG_PORT: "6379"
REDIS_CONFIG_DEBUG_DATABASE: "0"
TRUCKWASH_TEST_BLOCK_REAL_SHELLY: "1"
EDGE_GATEWAY_VIEW_CACHE_TTL: "0"
volumes:
- ci_php_app:/var/www/html
+83 -151
View File
@@ -5,69 +5,42 @@ on:
push:
jobs:
unit:
name: Unit (required)
# Match the labels exposed by the Coolify-managed GitHub runner.
php:
name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
matrix:
suite: [unit, integration, api, legacy]
env:
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4
with:
node-version: 22
- name: Check AI workflow sync
if: ${{ matrix.suite == 'unit' }}
run: node scripts/sync-ai-workflow.mjs --check
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
- name: Run PHP ${{ matrix.suite }} suite
run: bash scripts/php-ci-test.sh ${{ matrix.suite }}
- name: Boot php1 test stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
- name: Sync PHP app checkout
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
- name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
- name: Run unit tests
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
- name: Generate coverage report
run: |
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && if php -m | grep -Eq '^(pcov|xdebug)$'; then composer test:coverage; else echo 'Skipping coverage: no pcov/xdebug extension available in php1 image.'; fi"
- name: Upload coverage artifact
if: ${{ github.event_name == 'pull_request' }}
- name: Upload PHP suite logs
if: ${{ failure() }}
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: unit-coverage-clover
path: services/nginx/app/build/logs/clover.xml
name: php-${{ matrix.suite }}-logs
path: .tmp/ci-logs/${{ matrix.suite }}
if-no-files-found: warn
retention-days: 1
- name: Tear down php1 test stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
retention-days: 3
edge-agent:
name: Edge Agent (required)
@@ -84,6 +57,30 @@ jobs:
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools
run: |
set -euo pipefail
if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then
exit 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2
exit 1
fi
apt_cmd=(apt-get)
if [ "$(id -u)" -ne 0 ]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "make and g++ are missing, and sudo is not available to install them." >&2
exit 1
fi
apt_cmd=(sudo apt-get)
fi
"${apt_cmd[@]}" update
"${apt_cmd[@]}" install -y --no-install-recommends build-essential python3
- name: Install dependencies
working-directory: services/edge-agent
run: npm ci
@@ -100,22 +97,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
- name: Materialize CI compose env files
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
- name: Validate compose contracts
run: |
@@ -153,22 +139,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
- name: Materialize CI compose env files
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -179,10 +154,16 @@ jobs:
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
- name: Sync PHP app checkout
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
run: >
tar
--exclude='./vendor'
--exclude='./.phpunit.cache'
--exclude='./build/logs'
-C services/nginx/app -cf - .
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
- name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
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"
- name: Verify edge gateway test files
run: >
@@ -200,7 +181,13 @@ jobs:
"cd /var/www/html &&
RUN_API_TESTS=1
API_TEST_BOOTSTRAP_SCHEMA=1
API_TEST_ALLOW_LIVE_DB=1
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
CONFIG_DB_PORT=3306
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
@@ -221,6 +208,11 @@ jobs:
"cd /var/www/html &&
RUN_INTEGRATION_TESTS=1
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=\${CONFIG_DB_USER:-root}
CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password}
CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug}
CONFIG_DB_PORT=3306
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root}
CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
@@ -262,86 +254,26 @@ jobs:
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
integration:
name: Integration (advisory)
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
continue-on-error: true
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
- name: Record Release Manager API gate
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
- name: Boot integration stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
- name: Sync PHP app checkout
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
- name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
- name: Run integration tests
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T -e RUN_INTEGRATION_TESTS=1 php1 sh -lc "cd /var/www/html && composer test:integration"
- name: Tear down integration stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
api:
name: API (advisory)
runs-on: [self-hosted, Linux, X64, default]
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
- name: Boot API stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
- name: Sync PHP app checkout
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
- name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
- name: Run API tests
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T -e RUN_API_TESTS=1 -e API_TEST_BOOTSTRAP_SCHEMA=1 php1 sh -lc "cd /var/www/html && composer test:api"
- name: Tear down API stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+3 -2
View File
@@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
# Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
# Install PHP dependencies through Composer (only where composer.json exists)
# Main app dependencies
@@ -72,4 +73,4 @@ EXPOSE 80 443
ENTRYPOINT ["docker-entrypoint.sh"]
# Start services when no command is provided (docker-compose overrides this with ["php-fpm"])
CMD ["php-fpm"]
CMD ["php-fpm"]
+73
View File
@@ -0,0 +1,73 @@
FROM php:8.2.15-fpm
WORKDIR /var/www/html
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
$PHPIZE_DEPS \
ca-certificates \
curl \
default-mysql-client \
git \
imagemagick \
libfreetype6-dev \
libjpeg62-turbo-dev \
libmagickcore-dev \
libmagickwand-dev \
libonig-dev \
libpng-dev \
libssl-dev \
libxml2-dev \
libzip-dev \
mariadb-client \
nginx \
pkg-config \
redis-tools \
unzip \
zip; \
update-ca-certificates; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
bcmath \
exif \
gd \
mbstring \
mysqli \
pcntl \
pdo_mysql \
sockets \
zip; \
pecl install imagick-3.7.0 redis; \
docker-php-ext-enable imagick redis; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/ /var/www/html/
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
RUN set -eux; \
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
if [ -f /var/www/html/modules/washcertificates/composer.json ]; then \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html/modules/washcertificates; \
fi; \
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
chown -R www-data:www-data /var/www/html; \
chmod -R 755 /var/www/html
ENV APP_DIR=/var/www/html \
MODULE_DIR=/var/www/html/modules/washcertificates \
AUTO_COMPOSER_INSTALL=false \
COMPOSER_ALLOW_SUPERUSER=1
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["coolify-api-start"]
+1 -1
View File
@@ -10,7 +10,7 @@ $CONFIG_DB = [
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
$ECONOMIC_API = [
'app_access_grant' => '', // Economic API access grant token (1)
'app_access_grant2' => '', // Economic API access grant token (2)
+15 -1
View File
@@ -96,6 +96,13 @@ services:
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-v2.tls=true"
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-v2.priority=200"
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
@@ -153,7 +160,14 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+1 -1
View File
@@ -27,5 +27,5 @@ services:
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
##
## Notes:
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io and traefik.truckwash.dk point to this host and ports 80/443 are reachable.
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io, api-v2.truckwash.io and traefik.truckwash.dk point to the expected ingress and ports 80/443 are reachable.
## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production.
+23 -1
View File
@@ -49,6 +49,13 @@ services:
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-io.priority=200"
- "traefik.http.routers.edge-broker-io.service=edge-broker"
- "traefik.http.routers.edge-broker-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-v2.tls=true"
- "traefik.http.routers.edge-broker-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-v2.priority=200"
- "traefik.http.routers.edge-broker-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-staging.tls=true"
@@ -133,6 +140,13 @@ services:
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-v2.tls=true"
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-v2.priority=200"
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
@@ -192,8 +206,16 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
# Public API (.io load-balanced gateway)
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
# HTTP to HTTPS redirect for both API domains
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+3 -3
View File
@@ -38,14 +38,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
@@ -65,4 +65,4 @@ http {
location ~* \.(cgi|shtml|phtml)$ {
}
}
}
}
+3 -3
View File
@@ -52,14 +52,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
@@ -80,4 +80,4 @@ http {
# Additional SSL options or configurations can be placed here, if necessary.
}
}
}
}
+541 -5
View File
@@ -72,10 +72,14 @@ tags:
description: Form submissions and management
- name: Worker
description: System worker status and maintenance
- name: Error Reports
description: Authenticated application error reporting
- name: Plate Scans
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Release Manager
description: Release channel, deployment, and operation management
- name: Branding
description: Branding options management
- name: Roles
@@ -90,6 +94,160 @@ tags:
description: Voice Calls via Bird
paths:
/error-reports:
post:
tags:
- Error Reports
summary: Submit an authenticated user error report
operationId: submitErrorReport
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportSubmissionRequest'
responses:
'201':
description: Error report submitted
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports:
get:
tags:
- Error Reports
summary: List error reports for superusers
operationId: listSuperuserErrorReports
security:
- BearerAuth: []
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [open, resolved, all]
default: open
- name: q
in: query
required: false
schema:
type: string
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: Error reports retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportListResponse'
'403':
description: Missing superuser error report permission
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports/{id}:
get:
tags:
- Error Reports
summary: Get an error report detail
operationId: getSuperuserErrorReport
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Error report retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'404':
description: Error report not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/superuser/error-reports/{id}/status:
patch:
tags:
- Error Reports
summary: Mark an error report open or resolved
operationId: updateSuperuserErrorReportStatus
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportStatusUpdateRequest'
responses:
'200':
description: Error report status updated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReportResponse'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Missing superuser error report resolve permission
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Bird Voice Calls
/bird/voice/calls:
post:
@@ -3874,13 +4032,15 @@ paths:
- name: buttons
in: query
required: false
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
schema:
oneOf:
- type: string
- type: array
items:
type: integer
oneOf:
- type: integer
- type: string
- name: current_step
in: query
required: false
@@ -5688,7 +5848,7 @@ paths:
reference: {type: string}
po: {type: string}
pickup: {type: boolean}
order_id: {type: integer}
order_id: {type: integer, nullable: true}
items:
type: array
items:
@@ -7545,7 +7705,15 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema: {}
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
/worker/debug:
get:
@@ -8797,6 +8965,10 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -8847,6 +9019,9 @@ paths:
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
derived from the tasks currently shown to the user after answering the self-serve questions.
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
confirming a wash start.
operationId: setSelfServeLaneAllowedServices
requestBody:
required: true
@@ -9290,7 +9465,9 @@ paths:
description: |
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
enabled; an explicit call to this endpoint is required.
enabled; an explicit call to this endpoint is required. Operator callers require
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
operationId: enableSelfServeLaneMachineRelay
@@ -11826,6 +12003,189 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/superuser/releases/operations:
get:
tags:
- Release Manager
summary: List release operation runs
operationId: listReleaseOperations
parameters:
- in: query
name: channel_id
schema:
type: integer
- in: query
name: operation_type
schema:
type: string
- in: query
name: status
schema:
type: string
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 200
responses:
'200':
description: Release operation runs
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/releases/operations/{id}:
get:
tags:
- Release Manager
summary: Get release operation details
operationId: getReleaseOperation
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Release operation details
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/releases/test-runs:
post:
tags:
- Release Manager
summary: Run Release Manager diagnostics
operationId: runReleaseTest
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Release test operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/channels/{id}/sync:
post:
tags:
- Release Manager
summary: Sync latest branch commits into a release channel
operationId: syncReleaseChannel
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'202':
description: Channel sync operation started
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/superuser/releases/issues/actions:
post:
tags:
- Release Manager
summary: Run a Release Manager issue action
operationId: runReleaseIssueAction
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issue_key:
type: string
action_id:
type: string
inputs:
type: object
additionalProperties: true
confirm:
type: boolean
additionalProperties: true
responses:
'200':
description: Issue action result
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
additionalProperties: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
components:
securitySchemes:
BearerAuth:
@@ -11939,6 +12299,182 @@ components:
type: integer
description: HTTP status code
ErrorReportSubmissionRequest:
type: object
required:
- before_error
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
maxLength: 4000
description: What the user was doing before the error occurred
expected:
type: string
maxLength: 4000
description: What the user expected would happen
actual:
type: string
maxLength: 4000
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
route_path:
type: string
nullable: true
page_url:
type: string
nullable: true
release_trace_id:
type: string
nullable: true
request_errors:
type: array
items:
type: object
additionalProperties: true
vue_errors:
type: array
items:
type: object
additionalProperties: true
context:
type: object
additionalProperties: true
ErrorReportStatusUpdateRequest:
type: object
required:
- status
properties:
status:
type: string
enum: [open, resolved]
resolution_note:
type: string
nullable: true
maxLength: 2000
ErrorReportResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/ErrorReport'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
ErrorReportListResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ErrorReport'
counts:
type: object
properties:
open:
type: integer
resolved:
type: integer
all:
type: integer
limit:
type: integer
offset:
type: integer
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
ErrorReport:
type: object
properties:
id:
type: integer
status:
type: string
enum: [open, resolved]
reporter:
type: object
additionalProperties: true
route_path:
type: string
nullable: true
page_url:
type: string
nullable: true
release_trace_id:
type: string
nullable: true
frontend_version:
type: string
nullable: true
api_version:
type: string
nullable: true
screenshot:
type: object
additionalProperties: true
answers:
type: object
properties:
before_error:
type: string
expected:
type: string
actual:
type: string
request_error_count:
type: integer
vue_error_count:
type: integer
request_errors:
type: array
items:
type: object
additionalProperties: true
vue_errors:
type: array
items:
type: object
additionalProperties: true
runtime_context:
type: object
additionalProperties: true
resolved_at:
type: string
nullable: true
resolved_by_user_id:
type: integer
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
SuperuserSystemStatusResponse:
type: object
properties:
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
keepalive_timeout 65;
client_max_body_size 64m;
gzip on;
gzip_types application/json application/javascript application/xml text/css text/plain;
server {
listen 80 default_server;
server_name _;
root /var/www/html;
index index.php;
location / {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param X_REQUEST_ID $http_x_request_id;
}
location ~ /\.(?!well-known) {
deny all;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
mkdir -p /run/nginx
php-fpm -D
exec nginx -g "daemon off;"
@@ -45,6 +45,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
@@ -70,6 +71,7 @@ test("standalone production compose routes edge broker traffic through traefik",
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace classes;
require_once WD . '/interfaces/universal_module_i.php';
require_once WD . '/modules/coolify/coolify_c.php';
use Exception;
use interfaces\universal_module_i;
use modules\coolify\coolify_c;
class coolify implements universal_module_i
{
public coolify_c $config;
public function __construct()
{
$this->config = new coolify_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The Coolify module is not enabled');
}
}
public function isEnabled(): bool
{
try {
return $this->config->enabled->isTrue();
} catch (Exception) {
return false;
}
}
}
@@ -0,0 +1,264 @@
<?php
namespace classes;
use RuntimeException;
class coolify_api_client
{
private string $baseUrl;
private string $token;
private int $timeoutSeconds;
public function __construct(string $baseUrl, string $token, int $timeoutSeconds = 4)
{
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
$this->token = trim($token);
$this->timeoutSeconds = max(1, $timeoutSeconds);
if ($this->baseUrl === '' || $this->token === '') {
throw new RuntimeException('Coolify base URL and API token are required.');
}
}
public static function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = rtrim(trim($baseUrl), '/');
if ($baseUrl === '') {
return '';
}
if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) {
return $baseUrl;
}
return $baseUrl . '/api/v1';
}
public function healthcheck(): array
{
return $this->request('GET', '/health', null, false);
}
public function version(): array
{
return $this->request('GET', '/version');
}
public function listServers(): array
{
return $this->request('GET', '/servers');
}
public function listProjects(): array
{
return $this->request('GET', '/projects');
}
public function listProjectEnvironments(string $projectUuid): array
{
return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments');
}
public function listServices(): array
{
return $this->request('GET', '/services');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
}
public function getService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid));
}
public function createService(array $payload): array
{
return $this->request('POST', '/services', $payload);
}
public function createPrivateGithubAppApplication(array $payload): array
{
return $this->request('POST', '/applications/private-github-app', $payload);
}
public function getApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid));
}
public function updateApplication(string $uuid, array $payload): array
{
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload);
}
public function updateService(string $uuid, array $payload): array
{
return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload);
}
public function updateServiceEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
public function updateApplicationEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
private static function bulkEnvData(array $env): array
{
$data = [];
foreach ($env as $key => $value) {
$data[] = [
'key' => (string)$key,
'value' => (string)$value,
'is_preview' => false,
'is_literal' => true,
'is_multiline' => str_contains((string)$value, "\n"),
'is_shown_once' => false,
];
}
return $data;
}
public function deployResource(string $uuid, bool $force = false): array
{
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
return $this->request('GET', $path);
}
public function startService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
}
public function restartService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart');
}
public function restartApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
}
public function deleteService(string $uuid): array
{
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
}
protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array
{
$url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/');
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize Coolify API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Coolify API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Coolify API request failed: ' . $error);
}
$decoded = null;
if (trim((string)$raw) !== '') {
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
}
if ($status < 200 || $status >= 300) {
$message = is_array($decoded)
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
: ('HTTP ' . $status);
if (is_array($decoded)) {
$details = self::validationErrorSummary($decoded);
if ($details !== '') {
$message .= ': ' . $details;
}
}
throw new RuntimeException('Coolify API request failed: ' . $message);
}
return is_array($decoded) ? $decoded : [];
}
private static function validationErrorSummary(array $decoded): string
{
$errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null;
if (!is_array($errors)) {
return '';
}
$parts = [];
foreach ($errors as $field => $messages) {
$fieldName = trim((string)$field);
$fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : '';
if (is_array($messages)) {
$messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages)));
} else {
$messages = trim((string)$messages);
}
if ($messages !== '') {
$parts[] = $fieldPrefix . $messages;
}
}
return implode('; ', array_slice($parts, 0, 5));
}
private function apiRootUrl(): string
{
return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for the Coolify infrastructure integration.
*
* The legacy stack has no centralized migration runner, so this class must be
* safe to call from request handlers, cron, and tests.
*/
class coolify_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS coolify_instances (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(128) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_token_secret TEXT NOT NULL,
default_project_uuid VARCHAR(128) NULL,
default_environment_uuid VARCHAR(128) NULL,
default_environment_name VARCHAR(128) NULL,
default_server_uuid VARCHAR(128) NULL,
default_destination_uuid VARCHAR(128) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_checked_at DATETIME NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_coolify_instances_status (status),
INDEX idx_coolify_instances_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NOT NULL,
replication_host_id BIGINT UNSIGNED NULL,
kind VARCHAR(16) NOT NULL,
label VARCHAR(128) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'replica',
server_uuid VARCHAR(128) NULL,
project_uuid VARCHAR(128) NULL,
environment_uuid VARCHAR(128) NULL,
environment_name VARCHAR(128) NULL,
destination_uuid VARCHAR(128) NULL,
resource_uuid VARCHAR(128) NULL,
resource_type VARCHAR(32) NOT NULL DEFAULT 'service',
resource_name VARCHAR(128) NULL,
deployment_status VARCHAR(32) NOT NULL DEFAULT 'pending',
availability_state VARCHAR(32) NOT NULL DEFAULT 'degraded',
desired_compose_hash CHAR(64) NULL,
last_reconcile_status VARCHAR(32) NULL,
last_reconcile_json LONGTEXT NULL,
last_reconciled_at DATETIME NULL,
options_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_coolify_targets_instance (instance_id),
INDEX idx_coolify_targets_replication_host (replication_host_id),
INDEX idx_coolify_targets_kind_status (kind, deployment_status),
INDEX idx_coolify_targets_availability (availability_state),
INDEX idx_coolify_targets_resource_uuid (resource_uuid),
INDEX idx_coolify_targets_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_operations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
operation VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
guarded TINYINT(1) NOT NULL DEFAULT 1,
message VARCHAR(512) NULL,
error_message TEXT NULL,
context_json LONGTEXT NULL,
actor_user_id INT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_coolify_operations_target (target_id),
INDEX idx_coolify_operations_instance (instance_id),
INDEX idx_coolify_operations_status (status),
INDEX idx_coolify_operations_operation (operation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
replication_host_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_coolify_audit_target (target_id),
INDEX idx_coolify_audit_instance (instance_id),
INDEX idx_coolify_audit_replication_host (replication_host_id),
INDEX idx_coolify_audit_action (action),
INDEX idx_coolify_audit_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_instance_gateways (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NULL,
hostname VARCHAR(255) NOT NULL,
target_ip VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
priority INT NOT NULL DEFAULT 100,
health_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
lb_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_probe_json LONGTEXT NULL,
last_probed_at DATETIME NULL,
last_reconciled_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uniq_coolify_instance_gateways_target_ip (target_ip),
INDEX idx_coolify_instance_gateways_instance (instance_id),
INDEX idx_coolify_instance_gateways_enabled (enabled),
INDEX idx_coolify_instance_gateways_lb_state (lb_state),
INDEX idx_coolify_instance_gateways_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL');
self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'");
self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL');
self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL');
self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1');
self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL');
self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30);
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return self::$tablesExist;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void
{
global $db;
$targetIpSql = $db->escape_string($targetIp);
$result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$hostnameSql = $db->escape_string($hostname);
$db->query(
"INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority)
VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")"
);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace classes;
class cors_policy
{
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [
'https://truckwash.io',
'https://www.truckwash.io',
'https://api.truckwash.io',
'https://api.truckwash.io:4433',
'https://api-v2.truckwash.io',
'https://web.truckwash.dk',
'https://api.truckwash.dk',
'https://truckwash.dk',
'https://www.truckwash.dk',
'https://staging.truckwash.io',
'http://localhost',
'https://localhost',
'http://localhost:4433',
'https://localhost:4433',
'https://twdev.jeppeb.dk',
'http://localhost:5173',
];
public static function normalizeOrigin(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || $value === '*') {
return $value;
}
if (preg_match('#^https?://#i', $value) !== 1) {
return '';
}
$parts = parse_url($value);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return '';
}
$scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return $scheme . '://' . $host . $port;
}
/**
* @return array<int,string>
*/
public static function requiredAllowedOrigins(): array
{
return self::REQUIRED_ALLOWED_ORIGINS;
}
/**
* @return array<int,string>
*/
public static function allowedOrigins(string $corsConfig): array
{
$origins = [];
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
if ($configuredOrigin === '*') {
return ['*'];
}
$origin = self::normalizeOrigin($configuredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
$origin = self::normalizeOrigin($requiredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
return array_keys($origins);
}
public static function withRequiredOrigins(string $corsConfig): string
{
$allowedOrigins = self::allowedOrigins($corsConfig);
if ($allowedOrigins === ['*']) {
return '*';
}
return implode(',', $allowedOrigins);
}
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || $origin === '*') {
return false;
}
$allowedOrigins = self::allowedOrigins($corsConfig);
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
}
/**
* @return array<string,string>
*/
public static function responseHeaders(?string $origin, string $corsConfig): array
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return [];
}
return [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Vary' => 'Origin',
];
}
/**
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
*/
public static function preflightResponse(?string $origin, string $corsConfig): array
{
$headers = self::responseHeaders($origin, $corsConfig);
if ($headers === []) {
return [
'allowed' => false,
'status' => 403,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
];
}
$headers['Content-Type'] = 'application/json';
return [
'allowed' => true,
'status' => 200,
'headers' => $headers,
'body' => '',
];
}
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
{
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
if ($headers === []) {
return false;
}
self::emitHeaders($headers);
return true;
}
/**
* @param array<string,string> $headers
*/
public static function emitHeaders(array $headers): void
{
foreach ($headers as $name => $value) {
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
}
}
/**
* @return array<int,string>
*/
private static function splitOrigins(string $corsConfig): array
{
return array_values(array_filter(
array_map('trim', explode(',', $corsConfig)),
static fn(string $origin): bool => $origin !== ''
));
}
}
+60 -3
View File
@@ -165,9 +165,17 @@ class economic implements economic_i
/**
* Create a customer in e-conomic and return the raw upstream payload.
*/
public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): object
public function createCustomer(
int $customer_number,
string $name,
int $cvr_number,
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
): object
{
return $this->customers->customers->create([
$payload = [
'customerNumber' => $customer_number,
'corporateIdentificationNumber' => (string)$cvr_number,
'customerGroup' => [
@@ -179,11 +187,60 @@ class economic implements economic_i
'name' => $name,
'email' => $email,
'phone' => $phone,
'telephoneAndFaxNumber' => (string)$phone,
'mobilePhone' => (string)($mobile_phone ?? $phone),
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
]
]);
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
return $this->customers->customers->create($payload);
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
return [];
}
$payload = [];
$field_map = [
'address' => 'address',
'zipcode' => 'zip',
'city' => 'city',
'website' => 'website',
];
foreach ($field_map as $source_field => $economic_field) {
$value = $this->companyInformationValue($company_information, $source_field);
if ($value === null) {
continue;
}
$payload[$economic_field] = $value;
}
return $payload;
}
private function companyInformationValue(object|array $company_information, string $field): ?string
{
if (is_array($company_information)) {
$value = $company_information[$field] ?? null;
} else {
$value = $company_information->{$field} ?? null;
}
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
@@ -892,6 +892,7 @@ class economic_v2_distribution_service
$weight_total = array_sum($eligible_weights);
if (abs($weight_total) <= self::EPSILON) {
$fallback_department_id = $this->getFallbackDistributionDepartmentId();
$warnings[] = 'Booked department 75 '
. $source_category
. ' amount for customer '
@@ -900,11 +901,15 @@ class economic_v2_distribution_service
. $month_key
. ' on invoice(s) '
. $invoice_ids
. ' has no redistribution basis and remains undistributed.';
. ' has no redistribution basis and was assigned to fallback department '
. $fallback_department_id
. '.';
return [
'department_distribution' => [],
'undistributed_net_amount' => $booked_amount,
'department_distribution' => [
$fallback_department_id => $booked_amount,
],
'undistributed_net_amount' => 0.0,
];
}
@@ -1061,6 +1066,9 @@ class economic_v2_distribution_service
} elseif (!$this->isOrderEligible($order)) {
continue;
}
$distribution_department_id = $system_order_fallback
? $this->getFallbackDistributionDepartmentId()
: $department_id;
$fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at);
if ($fixed_version === null) {
@@ -1090,14 +1098,14 @@ class economic_v2_distribution_service
$order_original_price = $this->calculateOrderOriginalPrice(
$order_items_by_order_id[$order_id] ?? [],
$customer_number,
$department_id,
$distribution_department_id,
$created_at
);
$groups[$group_key]['original_price'] += $order_original_price;
if (!isset($groups[$group_key]['department_totals'][$department_id])) {
$groups[$group_key]['department_totals'][$department_id] = 0.0;
if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) {
$groups[$group_key]['department_totals'][$distribution_department_id] = 0.0;
}
$groups[$group_key]['department_totals'][$department_id] += $order_original_price;
$groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price;
$groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) {
@@ -1141,12 +1149,15 @@ class economic_v2_distribution_service
} elseif (!$this->isOrderEligible($order)) {
continue;
}
$distribution_department_id = $system_order_fallback
? $this->getFallbackDistributionDepartmentId()
: $department_id;
$month_key = substr($created_at, 0, 7);
if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) {
$customer_department_month_map[$customer_number][$month_key][$department_id] = 0;
if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) {
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0;
}
$customer_department_month_map[$customer_number][$month_key][$department_id]++;
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++;
$candidates = $this->buildWashSubscriptionCandidates(
$order,
@@ -1193,10 +1204,10 @@ class economic_v2_distribution_service
];
}
if (!isset($groups[$group_key]['distribution'][$department_id])) {
$groups[$group_key]['distribution'][$department_id] = 0;
if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) {
$groups[$group_key]['distribution'][$distribution_department_id] = 0;
}
$groups[$group_key]['distribution'][$department_id]++;
$groups[$group_key]['distribution'][$distribution_department_id]++;
$groups[$group_key]['order_ids'][] = $order_id;
$matched_order = true;
}
@@ -1394,6 +1405,9 @@ class economic_v2_distribution_service
): array {
$distribution = [];
$department_counts = $customer_department_month_map[$customer_number][$month_key] ?? [];
if (!empty($department_counts)) {
$department_counts = $this->normalizeFallbackDepartmentCounts($department_counts);
}
if (!empty($department_counts)) {
$total = (float)array_sum($department_counts);
foreach ($department_counts as $department_id => $count) {
@@ -1402,20 +1416,61 @@ class economic_v2_distribution_service
return $distribution;
}
$default_department = 1;
try {
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
if (!empty($default)) {
$default_department = (int)$default;
}
} catch (Exception $e) {
// fall back to department 1
}
$customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number);
$default_department = $customer_default_department_id !== null && $customer_default_department_id > 0
? $customer_default_department_id
: $this->getFallbackDistributionDepartmentId();
$distribution[$default_department] = $monthly_price;
return $distribution;
}
/**
* @param array<int|string,int|float> $department_counts
* @return array<int,int|float>
*/
private function normalizeFallbackDepartmentCounts(array $department_counts): array
{
$normalized = [];
foreach ($department_counts as $department_id => $count) {
$department_id = (int)$department_id;
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
$department_id = $this->getFallbackDistributionDepartmentId();
}
if (!isset($normalized[$department_id])) {
$normalized[$department_id] = 0;
}
$normalized[$department_id] += $count;
}
return $normalized;
}
protected function getCustomerDefaultDepartmentId(int $customer_number): ?int
{
try {
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
return !empty($default) ? (int)$default : null;
} catch (Exception $e) {
return null;
}
}
protected function getFallbackDistributionDepartmentId(): int
{
try {
$department_id = (new economic())->getDefaultDistributionDepartmentId();
} catch (\Throwable $e) {
$department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) {
return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
return $department_id;
}
private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array
{
if (empty($distribution)) {
@@ -0,0 +1,76 @@
<?php
namespace classes;
class error_report_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$db->query("CREATE TABLE IF NOT EXISTS error_reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
status VARCHAR(16) NOT NULL DEFAULT 'open',
reporter_type VARCHAR(16) NOT NULL,
reporter_user_id INT NULL,
reporter_subuser_id INT NULL,
reporter_customer_number INT NULL,
reporter_customer_number_context INT NULL,
reporter_name VARCHAR(255) NULL,
reporter_email VARCHAR(255) NULL,
route_path VARCHAR(512) NULL,
page_url VARCHAR(1024) NULL,
release_trace_id VARCHAR(64) NULL,
frontend_version VARCHAR(128) NULL,
api_version VARCHAR(128) NULL,
screenshot_object_key VARCHAR(512) NOT NULL,
screenshot_mime_type VARCHAR(64) NOT NULL,
screenshot_size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
before_error TEXT NOT NULL,
expected TEXT NOT NULL,
actual TEXT NOT NULL,
request_error_count INT UNSIGNED NOT NULL DEFAULT 0,
vue_error_count INT UNSIGNED NOT NULL DEFAULT 0,
request_errors_json LONGTEXT NULL,
vue_errors_json LONGTEXT NULL,
runtime_context_json LONGTEXT NULL,
data_collection_accepted TINYINT(1) NOT NULL DEFAULT 0,
data_collection_accepted_at DATETIME NOT NULL,
data_collection_policy_version VARCHAR(64) NOT NULL DEFAULT 'error-report-v1',
resolved_at DATETIME NULL,
resolved_by_user_id INT NULL,
resolution_note TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_error_reports_status_created (status, created_at),
INDEX idx_error_reports_reporter_user (reporter_user_id, created_at),
INDEX idx_error_reports_reporter_subuser (reporter_subuser_id, created_at),
INDEX idx_error_reports_customer (reporter_customer_number, reporter_customer_number_context),
INDEX idx_error_reports_trace (release_trace_id),
INDEX idx_error_reports_route (route_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
$result = $db->query("SHOW TABLES LIKE 'error_reports'");
self::$tablesExist = $result !== false && $result->num_rows > 0;
return self::$tablesExist;
}
}
@@ -0,0 +1,567 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class error_report_service
{
private const ANSWER_MAX_LENGTH = 4000;
private const NOTE_MAX_LENGTH = 2000;
private const SCREENSHOT_MAX_BYTES = 6_000_000;
private const JSON_MAX_LENGTH = 200_000;
private const STATUS_OPEN = 'open';
private const STATUS_RESOLVED = 'resolved';
private bool $schemaEnsured = false;
private error_report_store $store;
public function __construct(?error_report_store $store = null)
{
$this->store = $store ?? new error_report_store();
}
public static function redactPayload(mixed $value, int $depth = 0): mixed
{
if ($depth > 8) {
return '[depth-limit]';
}
if (is_array($value)) {
$redacted = [];
$index = 0;
foreach ($value as $key => $item) {
$index++;
if ($index > 80) {
$redacted['[truncated]'] = 'More than 80 keys omitted.';
break;
}
$keyString = (string)$key;
if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = self::redactPayload($item, $depth + 1);
}
return $redacted;
}
if (is_object($value)) {
return self::redactPayload((array)$value, $depth + 1);
}
if (is_string($value) && strlen($value) > 4000) {
return substr($value, 0, 4000) . "\n... [truncated]";
}
return $value;
}
public static function decodeScreenshotDataUri(string $dataUri): array
{
if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) {
throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.');
}
$contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true);
if ($contents === false || $contents === '') {
throw new RuntimeException('Screenshot could not be decoded.');
}
if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) {
throw new RuntimeException('Screenshot is too large.');
}
return [
'mime_type' => $matches[1],
'contents' => $contents,
'size_bytes' => strlen($contents),
];
}
public function createFromCurrentPrincipal(array $payload): array
{
$this->ensureSchema();
$principal = $this->resolvePrincipal();
$answers = $this->validatedAnswers($payload);
if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) {
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'] : [];
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$this->execute(
"INSERT INTO error_reports (
status,
reporter_type,
reporter_user_id,
reporter_subuser_id,
reporter_customer_number,
reporter_customer_number_context,
reporter_name,
reporter_email,
route_path,
page_url,
release_trace_id,
frontend_version,
api_version,
screenshot_object_key,
screenshot_mime_type,
screenshot_size_bytes,
before_error,
expected,
actual,
request_error_count,
vue_error_count,
request_errors_json,
vue_errors_json,
runtime_context_json,
data_collection_accepted,
data_collection_accepted_at,
data_collection_policy_version
) VALUES (
'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ?
)",
'siiiisssssssssisssiissss',
[
$principal['type'],
$principal['user_id'],
$principal['subuser_id'],
$principal['customer_number'],
$principal['customer_number_context'],
$principal['name'],
$principal['email'],
$runtimeContext['route_path'],
$runtimeContext['page_url'],
$runtimeContext['release_trace_id'],
$runtimeContext['frontend_version'],
$runtimeContext['api_version'],
$storedScreenshot['key'],
$storedScreenshot['mime_type'],
(int)$storedScreenshot['size_bytes'],
$answers['before_error'],
$answers['expected'],
$answers['actual'],
count($requestErrors),
count($vueErrors),
$this->jsonEncodeLimited(self::redactPayload($requestErrors)),
$this->jsonEncodeLimited(self::redactPayload($vueErrors)),
$this->jsonEncodeLimited(self::redactPayload($runtimeContext)),
$runtimeContext['data_collection_policy_version'],
]
);
return $this->get($this->insertId());
}
public function list(array $filters = []): array
{
$this->ensureSchema();
$where = ['1 = 1'];
$types = '';
$params = [];
$status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN);
if ($status !== 'all') {
$where[] = 'status = ?';
$types .= 's';
$params[] = $status;
}
$search = trim((string)($filters['q'] ?? $filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)';
$types .= 'ssssss';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like, $like, $like, $like);
}
$limit = min(200, max(1, (int)($filters['limit'] ?? 50)));
$offset = max(0, (int)($filters['offset'] ?? 0));
$types .= 'ii';
$params[] = $limit;
$params[] = $offset;
$items = $this->selectRows(
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email,
route_path, page_url, release_trace_id, frontend_version, api_version,
screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual,
request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at
FROM error_reports
WHERE " . implode(' AND ', $where) . "
ORDER BY created_at DESC
LIMIT ? OFFSET ?",
$types,
$params
);
return [
'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items),
'counts' => $this->counts(),
'limit' => $limit,
'offset' => $offset,
];
}
public function get(int $id): array
{
$this->ensureSchema();
$row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]);
if ($row === null) {
throw new RuntimeException('Error report not found.');
}
return $this->publicReport($row, true);
}
public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array
{
$this->ensureSchema();
$status = self::normalizeStatus($status);
$note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null;
if ($status === self::STATUS_RESOLVED) {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?',
'sisi',
[$status, $actorUserId, $note, $id]
);
} else {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?',
'ssi',
[$status, $note, $id]
);
}
return $this->get($id);
}
public static function normalizeStatus(string $status): string
{
$status = strtolower(trim($status));
if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) {
throw new RuntimeException('Invalid error report status.');
}
return $status;
}
private function validatedAnswers(array $payload): array
{
return [
'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']),
'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']),
'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']),
];
}
private function requiredAnswer(array $payload, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $payload)) {
return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true);
}
}
throw new RuntimeException('Missing required answer.');
}
private function trimmedString(string $value, int $maxLength, bool $required): string
{
$value = trim($value);
if ($required && $value === '') {
throw new RuntimeException('Required text fields must not be empty.');
}
if (strlen($value) > $maxLength) {
return substr($value, 0, $maxLength);
}
return $value;
}
private function acceptedDataCollection(mixed $value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function runtimeContext(array $payload, array $context): array
{
return [
'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512),
'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024),
'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64),
'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128),
'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128),
'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null,
'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024),
'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64),
'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1',
];
}
private function releaseRequestContext(string $key): ?string
{
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : [];
return isset($context[$key]) ? (string)$context[$key] : null;
}
private function nullableString(mixed $value, int $maxLength): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
if ($value === '') {
return null;
}
return substr($value, 0, $maxLength);
}
private function boundedArray(mixed $value, int $limit): array
{
return is_array($value) ? array_slice(array_values($value), 0, $limit) : [];
}
private function statusFilter(mixed $status): string
{
$status = strtolower(trim((string)$status));
if ($status === '' || $status === self::STATUS_OPEN) {
return self::STATUS_OPEN;
}
if ($status === self::STATUS_RESOLVED || $status === 'all') {
return $status;
}
return self::STATUS_OPEN;
}
private function counts(): array
{
$rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status');
$counts = [
self::STATUS_OPEN => 0,
self::STATUS_RESOLVED => 0,
'all' => 0,
];
foreach ($rows as $row) {
$status = (string)($row['status'] ?? '');
$count = (int)($row['count'] ?? 0);
if (isset($counts[$status])) {
$counts[$status] = $count;
}
$counts['all'] += $count;
}
return $counts;
}
private function resolvePrincipal(): array
{
$auth = new authentication();
try {
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'type' => 'subuser',
'user_id' => null,
'subuser_id' => (int)$subuser->id,
'customer_number' => null,
'customer_number_context' => $this->headerInt('X-Customer-Number'),
'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'),
'email' => $this->safeObjectValue($subuser, 'email'),
];
}
} catch (Throwable) {
}
try {
$user = $auth->get_user();
if ($user !== false) {
return [
'type' => 'user',
'user_id' => (int)$user->id,
'subuser_id' => null,
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
'customer_number_context' => null,
'name' => $this->safeObjectValue($user, 'display_name'),
'email' => $this->safeObjectValue($user, 'email'),
];
}
} catch (Throwable) {
}
throw new RuntimeException('Authentication failed. Invalid or missing token.');
}
private function headerInt(string $name): ?int
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
foreach ($headers as $key => $value) {
if (strcasecmp((string)$key, $name) === 0) {
$int = (int)$value;
return $int > 0 ? $int : null;
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$int = (int)($_SERVER[$serverKey] ?? 0);
return $int > 0 ? $int : null;
}
private function safeObjectValue(object $object, string $property): ?string
{
try {
if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) {
return null;
}
$value = $object->{$property}->value();
return $value === null ? null : substr((string)$value, 0, 255);
} catch (Throwable) {
return null;
}
}
private function publicReport(array $row, bool $includeDetail): array
{
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
'reporter' => [
'type' => $row['reporter_type'] ?? null,
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
'name' => $row['reporter_name'] ?? null,
'email' => $row['reporter_email'] ?? null,
],
'route_path' => $row['route_path'] ?? null,
'page_url' => $row['page_url'] ?? null,
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
'actual' => $row['actual'] ?? '',
],
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
'resolved_at' => $row['resolved_at'] ?? null,
'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
$report['data_collection'] = [
'accepted' => (bool)($row['data_collection_accepted'] ?? false),
'accepted_at' => $row['data_collection_accepted_at'] ?? null,
'policy_version' => $row['data_collection_policy_version'] ?? null,
];
$report['resolution_note'] = $row['resolution_note'] ?? null;
}
return $report;
}
private function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
error_report_schema_bootstrap::ensureTables();
$this->schemaEnsured = true;
}
private function selectOne(string $sql, string $types = '', array $params = []): ?array
{
$rows = $this->selectRows($sql, $types, $params);
return $rows[0] ?? null;
}
private function selectRows(string $sql, string $types = '', array $params = []): array
{
global $db;
if ($types === '') {
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report query.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function execute(string $sql, string $types = '', array $params = []): void
{
global $db;
if ($types === '') {
$db->query($sql);
return;
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report statement.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
private function jsonEncodeLimited(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode error report JSON payload.');
}
if (strlen($json) <= self::JSON_MAX_LENGTH) {
return $json;
}
$truncated = [
'[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.',
'preview' => substr($json, 0, self::JSON_MAX_LENGTH),
];
$encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '{}' : $encoded;
}
private function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,47 @@
<?php
namespace classes;
use traits\minio_t;
class error_report_store
{
use minio_t;
public function __construct()
{
self::setBucket('uploads');
}
public function storeScreenshot(string $mimeType, string $contents): array
{
$extension = match ($mimeType) {
'image/webp' => 'webp',
'image/jpeg' => 'jpg',
default => 'png',
};
$datePath = date('Y/m');
$key = sprintf('error-reports/%s/%s.%s', $datePath, bin2hex(random_bytes(16)), $extension);
if (!self::createObject($key, $contents)) {
throw new \RuntimeException('Could not store error report screenshot.');
}
return [
'key' => $key,
'mime_type' => $mimeType,
'size_bytes' => strlen($contents),
];
}
public function screenshotUrl(string $key): ?string
{
$key = trim($key);
if ($key === '') {
return null;
}
return self::getPresignedUrl($key, 1200, false);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace classes;
require_once WD . '/modules/failover/failover_c.php';
use failover\failover_c;
class failover
{
public failover_c $config;
public function __construct()
{
$this->config = new failover_c();
}
}
@@ -0,0 +1,151 @@
<?php
namespace classes;
use RuntimeException;
class hetzner_cloud_api_exception extends RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 0,
private readonly string $apiCode = ''
) {
parent::__construct($message, $statusCode);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function apiCode(): string
{
return $this->apiCode;
}
}
class hetzner_cloud_client
{
private const BASE_URL = 'https://api.hetzner.cloud/v1';
public function __construct(private readonly string $token, private readonly int $timeoutSeconds = 8)
{
if (trim($token) === '') {
throw new RuntimeException('Hetzner Cloud API token is required.');
}
}
public function getLoadBalancer(int|string $id): array
{
return $this->request('GET', '/load_balancers/' . rawurlencode((string)$id))['load_balancer'] ?? [];
}
public function addIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function removeIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/remove_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array
{
$payload = [
'protocol' => strtolower($protocol),
'listen_port' => $listenPort,
'destination_port' => $destinationPort,
'proxyprotocol' => false,
];
foreach (['health_check', 'http'] as $key) {
if (isset($options[$key]) && is_array($options[$key])) {
$payload[$key] = $options[$key];
}
}
return $payload;
}
private function request(string $method, string $path, ?array $payload = null): array
{
$curl = curl_init(self::BASE_URL . $path);
if ($curl === false) {
throw new RuntimeException('Could not initialize Hetzner Cloud API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . trim($this->token),
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(3, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, max(1, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Hetzner Cloud API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Hetzner Cloud API request failed: ' . $error);
}
$decoded = trim((string)$raw) === '' ? [] : json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
if ($status < 200 || $status >= 300) {
$errorPayload = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
$apiCode = (string)($errorPayload['code'] ?? $decoded['code'] ?? '');
$message = (string)($errorPayload['message'] ?? $decoded['message'] ?? ('HTTP ' . $status));
throw new hetzner_cloud_api_exception('Hetzner Cloud API request failed: ' . $message, $status, $apiCode);
}
return $decoded;
}
}
@@ -41,6 +41,8 @@ class orders_schema_bootstrap
);
}
self::backfillBookingPoDefaults($db);
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
@@ -49,6 +51,29 @@ class orders_schema_bootstrap
self::$initialized = true;
}
private static function backfillBookingPoDefaults(object $db): void
{
if (
!self::tableExists($db, 'order_bookings')
|| !self::columnExists($db, 'orders', 'booking_id')
|| !self::columnExists($db, 'orders', 'po')
|| !self::columnExists($db, 'order_bookings', 'po')
) {
return;
}
$db->query(
"UPDATE orders o
INNER JOIN order_bookings b ON b.id = o.booking_id
SET o.po = b.po
WHERE o.booking_id IS NOT NULL
AND o.booking_id > 0
AND (o.po IS NULL OR TRIM(o.po) = '')
AND b.po IS NOT NULL
AND TRIM(b.po) <> ''"
);
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,557 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for application release management.
*
* The legacy API does not have a centralized migration runner, so these
* migrations must be safe to call from request handlers, tests, and cron.
*/
class release_manager_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS release_channels (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
description TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
default_channel TINYINT(1) NOT NULL DEFAULT 0,
rollout_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
frontend_base_url VARCHAR(512) NULL,
api_base_url VARCHAR(512) NULL,
replay_enabled TINYINT(1) NOT NULL DEFAULT 0,
capture_level VARCHAR(32) NOT NULL DEFAULT 'metadata',
retention_days INT NOT NULL DEFAULT 14,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_channels_slug (slug),
INDEX idx_release_channels_enabled (enabled, deleted_at),
INDEX idx_release_channels_default (default_channel, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
tag VARCHAR(128) NULL,
version_label VARCHAR(128) NULL,
build_url VARCHAR(512) NULL,
artifact_url VARCHAR(512) NULL,
deployed_url VARCHAR(512) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'discovered',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deployed_at DATETIME NULL,
INDEX idx_release_versions_app_status (app, status),
INDEX idx_release_versions_commit (commit_sha),
INDEX idx_release_versions_repo_branch (repository, branch)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_channel_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
actor_user_id INT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_channel_versions_channel_active (channel_id, active),
INDEX idx_release_channel_versions_frontend (frontend_version_id),
INDEX idx_release_channel_versions_api (api_version_id),
INDEX idx_release_channel_versions_deployment (deployment_id),
INDEX idx_release_channel_versions_service_set (service_set_id),
INDEX idx_release_channel_versions_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_assignments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
channel_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(255) NULL,
expires_at DATETIME NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_assignments_subject (subject_type, subject_id, deleted_at, expires_at),
INDEX idx_release_assignments_channel (channel_id, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployment_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
coolify_instance_id BIGINT UNSIGNED NULL,
coolify_service_uuid VARCHAR(128) NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL DEFAULT 'main',
auto_deploy TINYINT(1) NOT NULL DEFAULT 1,
health_url VARCHAR(512) NULL,
deploy_context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_targets_channel_app (channel_id, app, deleted_at),
INDEX idx_release_targets_repo_branch (repository, branch, auto_deploy, deleted_at),
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'attach_existing',
source_service_set_id BIGINT UNSIGNED NULL,
frontend_target_id BIGINT UNSIGNED NULL,
api_target_id BIGINT UNSIGNED NULL,
database_coolify_target_id BIGINT UNSIGNED NULL,
redis_coolify_target_id BIGINT UNSIGNED NULL,
minio_coolify_target_id BIGINT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'needs_configuration',
health_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_service_sets_slug (slug),
INDEX idx_release_service_sets_channel (channel_id, deleted_at),
INDEX idx_release_service_sets_source (source_service_set_id),
INDEX idx_release_service_sets_status (status, deleted_at),
INDEX idx_release_service_sets_frontend_target (frontend_target_id),
INDEX idx_release_service_sets_api_target (api_target_id),
INDEX idx_release_service_sets_database_target (database_coolify_target_id),
INDEX idx_release_service_sets_redis_target (redis_coolify_target_id),
INDEX idx_release_service_sets_minio_target (minio_coolify_target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
target_id BIGINT UNSIGNED NULL,
version_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL,
active_channel_app_key VARCHAR(96) NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
provider_operation_id VARCHAR(128) NULL,
deployment_url VARCHAR(512) NULL,
actor_user_id INT NULL,
requested_payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
error_message TEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_deployments_channel_status (channel_id, status),
INDEX idx_release_deployments_target (target_id),
INDEX idx_release_deployments_version (version_id),
INDEX idx_release_deployments_service_set (service_set_id),
INDEX idx_release_deployments_bundle (bundle_id),
INDEX idx_release_deployments_kind (deployment_kind),
INDEX idx_release_deployments_commit (commit_sha)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_bundles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
service_set_id BIGINT UNSIGNED NOT NULL,
version_label VARCHAR(128) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
frontend_deployment_id BIGINT UNSIGNED NULL,
api_deployment_id BIGINT UNSIGNED NULL,
frontend_repository VARCHAR(255) NULL,
frontend_branch VARCHAR(128) NULL,
frontend_commit_sha VARCHAR(64) NULL,
api_repository VARCHAR(255) NULL,
api_branch VARCHAR(128) NULL,
api_commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
deployment_result_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
deployed_at DATETIME NULL,
promoted_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_bundles_channel_status (channel_id, status, deleted_at),
INDEX idx_release_bundles_service_set (service_set_id, status, deleted_at),
INDEX idx_release_bundles_frontend_version (frontend_version_id),
INDEX idx_release_bundles_api_version (api_version_id),
INDEX idx_release_bundles_frontend_deployment (frontend_deployment_id),
INDEX idx_release_bundles_api_deployment (api_deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_replay_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(16) NOT NULL,
target_id VARCHAR(64) NULL,
channel_id BIGINT UNSIGNED NULL,
capture_level VARCHAR(32) NOT NULL DEFAULT 'full_redacted',
enabled TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_replay_target (target_type, target_id, enabled, deleted_at, expires_at),
INDEX idx_release_replay_channel (channel_id, enabled, deleted_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(64) NOT NULL,
session_hash VARCHAR(64) NULL,
principal_type VARCHAR(16) NULL,
principal_id VARCHAR(64) NULL,
customer_number INT NULL,
channel_id BIGINT UNSIGNED NULL,
channel_slug VARCHAR(64) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
device_type VARCHAR(16) NULL,
browser_name VARCHAR(64) NULL,
browser_version VARCHAR(64) NULL,
os_name VARCHAR(64) NULL,
os_version VARCHAR(64) NULL,
viewport_width INT NULL,
viewport_height INT NULL,
device_pixel_ratio DECIMAL(6,3) NULL,
frontend_version_label VARCHAR(128) NULL,
frontend_commit_sha VARCHAR(128) NULL,
api_version_label VARCHAR(128) NULL,
api_commit_sha VARCHAR(128) NULL,
last_route_path VARCHAR(255) NULL,
user_agent VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_timeline_trace (trace_id),
INDEX idx_release_timeline_principal (principal_type, principal_id),
INDEX idx_release_timeline_channel (channel_id, channel_slug),
INDEX idx_release_timeline_device (device_type),
INDEX idx_release_timeline_release (frontend_version_label, api_version_label),
INDEX idx_release_timeline_customer (customer_number),
INDEX idx_release_timeline_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
timeline_session_id BIGINT UNSIGNED NULL,
trace_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
module_key VARCHAR(64) NULL,
route_path VARCHAR(255) NULL,
component VARCHAR(255) NULL,
request_id VARCHAR(128) NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_timeline_events_session (timeline_session_id, occurred_at),
INDEX idx_release_timeline_events_trace (trace_id, occurred_at),
INDEX idx_release_timeline_events_type (event_type, severity),
INDEX idx_release_timeline_events_module (module_key, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_module_health_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
module_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
reason VARCHAR(512) NULL,
payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_module_health_module (module_key, checked_at),
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_runs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(64) NOT NULL,
subject_type VARCHAR(64) NULL,
subject_id VARCHAR(128) NULL,
channel_id BIGINT UNSIGNED NULL,
app VARCHAR(16) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
title VARCHAR(255) NULL,
summary TEXT NULL,
solution_hint TEXT NULL,
actor_user_id INT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_runs_channel (channel_id, created_at),
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
INDEX idx_release_operation_runs_type_status (operation_type, status),
INDEX idx_release_operation_runs_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_run_id BIGINT UNSIGNED NOT NULL,
step_key VARCHAR(64) NOT NULL,
label VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
message TEXT NULL,
diagnostic TEXT NULL,
solution_hint TEXT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_steps_run (operation_run_id, id),
INDEX idx_release_operation_steps_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_audit_channel (channel_id, created_at),
INDEX idx_release_audit_deployment (deployment_id),
INDEX idx_release_audit_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('release_channel_versions', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER deployment_id');
self::ensureColumn('release_channel_versions', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
self::ensureColumn('release_timeline_sessions', 'os_name', 'VARCHAR(64) NULL AFTER browser_version');
self::ensureColumn('release_timeline_sessions', 'os_version', 'VARCHAR(64) NULL AFTER os_name');
self::ensureColumn('release_timeline_sessions', 'viewport_width', 'INT NULL AFTER os_version');
self::ensureColumn('release_timeline_sessions', 'viewport_height', 'INT NULL AFTER viewport_width');
self::ensureColumn('release_timeline_sessions', 'device_pixel_ratio', 'DECIMAL(6,3) NULL AFTER viewport_height');
self::ensureColumn('release_timeline_sessions', 'frontend_version_label', 'VARCHAR(128) NULL AFTER device_pixel_ratio');
self::ensureColumn('release_timeline_sessions', 'frontend_commit_sha', 'VARCHAR(128) NULL AFTER frontend_version_label');
self::ensureColumn('release_timeline_sessions', 'api_version_label', 'VARCHAR(128) NULL AFTER frontend_commit_sha');
self::ensureColumn('release_timeline_sessions', 'api_commit_sha', 'VARCHAR(128) NULL AFTER api_version_label');
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
self::ensureDefaultChannels();
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach ([
'release_channels',
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
'release_operation_runs',
'release_operation_steps',
'release_timeline_sessions',
'release_timeline_events',
] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return true;
}
private static function ensureDefaultChannels(): void
{
global $db;
$channels = [
['stable', 'Stable', 'Default production channel.', 1, 1, 'metadata'],
['canary', 'Canary', 'Earliest production validation channel.', 1, 0, 'metadata'],
['beta', 'Beta', 'Broader pre-stable rollout channel.', 1, 0, 'metadata'],
['internal', 'Internal', 'Internal staff and superuser validation channel.', 1, 0, 'metadata'],
];
foreach ($channels as [$slug, $name, $description, $enabled, $default, $captureLevel]) {
$db->query(sprintf(
"INSERT IGNORE INTO release_channels (slug, name, description, enabled, default_channel, capture_level)
VALUES ('%s', '%s', '%s', %d, %d, '%s')",
$db->escape_string($slug),
$db->escape_string($name),
$db->escape_string($description),
(int)$enabled,
(int)$default,
$db->escape_string($captureLevel)
));
}
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
}
private static function ensureIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
}
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
}
}
@@ -0,0 +1,19 @@
<?php
namespace classes;
class releasemanager
{
public function isEnabled(): bool
{
try {
release_manager_schema_bootstrap::ensureTables();
global $db;
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
$row = $result ? $result->fetch_assoc() : null;
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
} catch (\Throwable) {
return true;
}
}
}
@@ -0,0 +1,521 @@
<?php
namespace classes;
use Aws\S3\S3Client;
use mysqli;
use Predis\Client as PredisClient;
use Throwable;
class replica_failover_manager
{
public const KIND_DATABASE = 'database';
public const KIND_REDIS = 'redis';
public const KIND_MINIO = 'minio';
public const DEFAULT_MAX_STATUS_AGE_SECONDS = 90;
public static function configDefaults(): array
{
return [
'enabled' => false,
'database_enabled' => false,
'redis_enabled' => false,
'minio_enabled' => false,
'max_status_age_seconds' => self::DEFAULT_MAX_STATUS_AGE_SECONDS,
];
}
public static function normalizeConfig(array $config): array
{
$normalized = self::configDefaults();
foreach (['enabled', 'database_enabled', 'redis_enabled', 'minio_enabled'] as $key) {
if (array_key_exists($key, $config)) {
$normalized[$key] = self::boolValue($config[$key]);
}
}
if (array_key_exists('max_status_age_seconds', $config)) {
$normalized['max_status_age_seconds'] = max(1, (int)$config['max_status_age_seconds']);
}
return $normalized;
}
public static function kindEnabled(array $config, string $kind): bool
{
$config = self::normalizeConfig($config);
return $config['enabled'] && !empty($config[$kind . '_enabled']);
}
public static function snapshotHostIsStrictlyFresh(array $host, int $maxAgeSeconds, ?int $now = null): bool
{
if (($host['role'] ?? '') !== 'replica') {
return false;
}
if (!empty($host['deleted_at'])) {
return false;
}
$status = self::hostStatus($host);
if (($status['status'] ?? '') !== 'ok') {
return false;
}
if (round((float)($status['replication_percent'] ?? 0), 2) < 100.0) {
return false;
}
$blockers = $status['blockers'] ?? [];
if (is_array($blockers) && $blockers !== []) {
return false;
}
$checkedAt = self::hostCheckedAt($host, $status);
if ($checkedAt === null) {
return false;
}
return (($now ?? time()) - $checkedAt) <= max(1, $maxAgeSeconds);
}
public static function snapshotFailoverCandidate(array $hosts, string $kind, int $maxAgeSeconds, ?int $now = null): ?array
{
$eligible = array_values(array_filter(
$hosts,
static fn(array $host): bool => ($host['kind'] ?? '') === $kind
&& self::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds, $now)
));
if ($eligible === []) {
return null;
}
usort($eligible, static function (array $a, array $b) use ($now): int {
$aChecked = self::hostCheckedAt($a, self::hostStatus($a)) ?? 0;
$bChecked = self::hostCheckedAt($b, self::hostStatus($b)) ?? 0;
if ($aChecked === $bChecked) {
return (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
}
return $bChecked <=> $aChecked;
});
return $eligible[0];
}
public static function activeConfigFromHost(string $kind, array $host): ?array
{
if ($kind === self::KIND_DATABASE) {
$database = trim((string)($host['database_name'] ?? $host['database'] ?? ''));
$user = trim((string)($host['username'] ?? $host['user'] ?? ''));
if ($database === '' || $user === '') {
return null;
}
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 3306) ?: 3306,
'database' => $database,
'user' => $user,
'password_secret' => (string)($host['password_secret'] ?? ''),
'ssl_mode' => (string)($host['ssl_mode'] ?? 'DISABLED'),
];
}
if ($kind === self::KIND_REDIS) {
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 6379) ?: 6379,
'database' => (int)($host['database_index'] ?? $host['database'] ?? 0),
'user' => (string)($host['username'] ?? $host['user'] ?? ''),
'password_secret' => (string)($host['password_secret'] ?? ''),
];
}
if ($kind === self::KIND_MINIO) {
$options = self::jsonDecode($host['options_json'] ?? null);
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'endpoint' => self::minioEndpoint($host, $options),
'access_key' => (string)($host['username'] ?? $host['access_key'] ?? ''),
'secret_key_secret' => (string)($host['password_secret'] ?? ''),
'buckets' => is_array($options['buckets'] ?? null) ? array_values($options['buckets']) : [],
];
}
return null;
}
public static function applyStartupFailoverFromSnapshot(?string $path = null, array $probes = []): array
{
$snapshot = replication_bootstrap_config::loadSnapshot($path);
$failover = is_array($snapshot['failover'] ?? null) ? $snapshot['failover'] : [];
$config = self::normalizeConfig(is_array($failover['config'] ?? null) ? $failover['config'] : $failover);
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
$hostGroups = is_array($failover['hosts'] ?? null) ? $failover['hosts'] : [];
$maxAgeSeconds = (int)$config['max_status_age_seconds'];
$summary = [];
$changed = false;
$primaryDown = $probes['primary_down'] ?? [self::class, 'activePrimaryIsDown'];
$candidateReachable = $probes['candidate_reachable'] ?? [self::class, 'candidateReachable'];
$promoteCandidate = $probes['promote_candidate'] ?? [self::class, 'promoteCandidate'];
foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) {
if (!self::kindEnabled($config, $kind)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'disabled'];
continue;
}
if (!is_array($active[$kind] ?? null)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'missing_active_primary'];
continue;
}
try {
if (!call_user_func($primaryDown, $kind, $active[$kind], $snapshot)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'primary_healthy'];
continue;
}
$hosts = is_array($hostGroups[$kind] ?? null) ? $hostGroups[$kind] : [];
$candidate = self::snapshotFailoverCandidate($hosts, $kind, $maxAgeSeconds);
if ($candidate === null) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'no_fresh_caught_up_replica'];
continue;
}
if (!call_user_func($candidateReachable, $kind, $candidate)) {
$summary[$kind] = [
'status' => 'skipped',
'reason' => 'candidate_unreachable',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
continue;
}
call_user_func($promoteCandidate, $kind, $candidate);
$candidateActive = self::activeConfigFromHost($kind, $candidate);
if ($candidateActive === null) {
$summary[$kind] = [
'status' => 'skipped',
'reason' => 'candidate_missing_active_config',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
continue;
}
$snapshot['active'][$kind] = $candidateActive;
$pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : [];
$pending[] = [
'kind' => $kind,
'host_id' => (int)($candidate['id'] ?? 0),
'label' => (string)($candidate['label'] ?? ''),
'source' => 'startup_snapshot',
'promoted_at' => date('c'),
];
$snapshot['pending_failovers'] = $pending;
$summary[$kind] = [
'status' => 'promoted',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
$changed = true;
} catch (Throwable $throwable) {
$summary[$kind] = [
'status' => 'failed',
'reason' => $throwable->getMessage(),
];
}
}
if ($changed) {
$snapshot['generated_at'] = date('c');
replication_bootstrap_config::writeSnapshot($snapshot, $path);
if ($path === null) {
replication_bootstrap_config::applyToGlobals($snapshot);
}
}
return [
'changed' => $changed,
'results' => $summary,
];
}
public static function activePrimaryIsDown(string $kind, array $activeConfig, array $snapshot = []): bool
{
try {
match ($kind) {
self::KIND_DATABASE => self::probeActiveDatabase($activeConfig),
self::KIND_REDIS => self::probeActiveRedis($activeConfig),
self::KIND_MINIO => self::probeActiveMinio($activeConfig),
default => null,
};
return false;
} catch (Throwable) {
return true;
}
}
public static function candidateReachable(string $kind, array $host): bool
{
try {
match ($kind) {
self::KIND_DATABASE => self::probeHostDatabase($host),
self::KIND_REDIS => self::probeHostRedis($host),
self::KIND_MINIO => self::probeHostMinio($host),
default => null,
};
return true;
} catch (Throwable) {
return false;
}
}
public static function promoteCandidate(string $kind, array $host): void
{
match ($kind) {
self::KIND_DATABASE => self::promoteDatabaseCandidate($host),
self::KIND_REDIS => self::promoteRedisCandidate($host),
self::KIND_MINIO => self::probeHostMinio($host),
default => null,
};
}
private static function probeActiveDatabase(array $config): void
{
$host = (string)($config['host'] ?? '');
$user = (string)($config['user'] ?? '');
$database = (string)($config['database'] ?? '');
$password = self::activePassword($config, 'password_secret', 'password');
self::connectMysqli($host, $user, $password, $database, (int)($config['port'] ?? 3306))->close();
}
private static function probeHostDatabase(array $host): void
{
$credentials = self::hostCredentials($host);
$connection = self::connectMysqli(
(string)($host['host'] ?? ''),
$credentials['username'],
$credentials['password'],
(string)($host['database_name'] ?? ''),
(int)($host['port'] ?? 3306)
);
$connection->close();
}
private static function promoteDatabaseCandidate(array $host): void
{
$credentials = self::hostCredentials($host);
$user = $credentials['admin_username'] !== '' ? $credentials['admin_username'] : $credentials['username'];
$password = $credentials['admin_password'] !== '' ? $credentials['admin_password'] : $credentials['password'];
$connection = self::connectMysqli(
(string)($host['host'] ?? ''),
$user,
$password,
(string)($host['database_name'] ?? ''),
(int)($host['port'] ?? 3306)
);
try {
foreach (['STOP REPLICA', 'STOP SLAVE'] as $statement) {
try {
$connection->query($statement);
break;
} catch (Throwable) {
}
}
foreach (['SET GLOBAL super_read_only = OFF', 'SET GLOBAL read_only = OFF'] as $statement) {
try {
$connection->query($statement);
} catch (Throwable) {
}
}
} finally {
$connection->close();
}
}
private static function probeActiveRedis(array $config): void
{
self::redisClientFromConfig([
'host' => (string)($config['host'] ?? ''),
'port' => (int)($config['port'] ?? 6379),
'database' => (int)($config['database'] ?? 0),
'user' => (string)($config['user'] ?? ''),
'password' => self::activePassword($config, 'password_secret', 'password'),
])->ping();
}
private static function probeHostRedis(array $host): void
{
self::redisClientFromHost($host)->ping();
}
private static function promoteRedisCandidate(array $host): void
{
$client = self::redisClientFromHost($host);
$client->executeRaw(['REPLICAOF', 'NO', 'ONE']);
try {
$client->executeRaw(['CONFIG', 'REWRITE']);
} catch (Throwable) {
}
}
private static function probeActiveMinio(array $config): void
{
self::minioClientFromConfig([
'endpoint' => (string)($config['endpoint'] ?? ''),
'access_key' => (string)($config['access_key'] ?? $config['user'] ?? ''),
'secret_key' => self::activePassword($config, 'secret_key_secret', 'secret_key'),
])->listBuckets();
}
private static function probeHostMinio(array $host): void
{
self::minioClientFromHost($host)->listBuckets();
}
private static function connectMysqli(string $host, string $user, string $password, string $database, int $port): mysqli
{
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$connection = mysqli_init();
$connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2);
$connection->real_connect($host, $user, $password, $database, $port ?: 3306);
$connection->set_charset('utf8mb4');
return $connection;
}
private static function redisClientFromHost(array $host): PredisClient
{
$credentials = self::hostCredentials($host);
return self::redisClientFromConfig([
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 6379),
'database' => (int)($host['database_index'] ?? 0),
'user' => $credentials['username'],
'password' => $credentials['password'],
]);
}
private static function redisClientFromConfig(array $config): PredisClient
{
$params = [
'scheme' => 'tcp',
'host' => (string)$config['host'],
'port' => (int)$config['port'],
'database' => (int)$config['database'],
'password' => (string)$config['password'],
'timeout' => 2.0,
'read_write_timeout' => 2.0,
];
if (($config['user'] ?? '') !== '' && $config['user'] !== 'default') {
$params['username'] = (string)$config['user'];
}
return new PredisClient($params);
}
private static function minioClientFromHost(array $host): S3Client
{
$credentials = self::hostCredentials($host);
$options = self::jsonDecode($host['options_json'] ?? null);
return self::minioClientFromConfig([
'endpoint' => self::minioEndpoint($host, $options),
'access_key' => $credentials['username'],
'secret_key' => $credentials['password'],
]);
}
private static function minioClientFromConfig(array $config): S3Client
{
return new S3Client([
'version' => 'latest',
'region' => 'us-east-1',
'endpoint' => (string)$config['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => (string)$config['access_key'],
'secret' => (string)$config['secret_key'],
],
'http' => [
'connect_timeout' => 2,
'timeout' => 2,
],
]);
}
private static function minioEndpoint(array $host, array $options): string
{
$endpoint = trim((string)($options['endpoint'] ?? ''));
if ($endpoint !== '') {
return $endpoint;
}
$scheme = strtolower(trim((string)($options['scheme'] ?? 'http')));
if ($scheme !== 'https') {
$scheme = 'http';
}
return $scheme . '://' . (string)($host['host'] ?? '') . ':' . ((int)($host['port'] ?? 9000) ?: 9000);
}
private static function hostCredentials(array $host): array
{
return [
'username' => (string)($host['username'] ?? ''),
'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''),
'admin_username' => (string)($host['admin_username'] ?? ''),
'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''),
];
}
private static function activePassword(array $config, string $secretKey, string $plainKey): string
{
if (!empty($config[$secretKey])) {
return replication_secret_box::decrypt((string)$config[$secretKey]);
}
return (string)($config[$plainKey] ?? '');
}
private static function hostStatus(array $host): array
{
if (isset($host['last_status']) && is_array($host['last_status'])) {
return $host['last_status'];
}
return self::jsonDecode($host['last_status_json'] ?? null);
}
private static function hostCheckedAt(array $host, array $status): ?int
{
$raw = $host['last_checked_at'] ?? $status['checked_at'] ?? null;
if (!is_string($raw) || trim($raw) === '') {
return null;
}
$timestamp = strtotime($raw);
return $timestamp === false ? null : $timestamp;
}
private static function boolValue(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private static function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -67,6 +67,7 @@ class replication_bootstrap_config
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
$database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null);
$redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null);
$minio = self::activeMinioConfigFromSnapshot($active['minio'] ?? null);
if ($database !== null) {
$GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database);
@@ -75,6 +76,10 @@ class replication_bootstrap_config
if ($redis !== null) {
$GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis);
}
if ($minio !== null) {
$GLOBALS['MINIO'] = array_merge($GLOBALS['MINIO'] ?? [], $minio);
}
} catch (Throwable $throwable) {
error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage());
}
@@ -123,6 +128,26 @@ class replication_bootstrap_config
];
}
public static function activeMinioConfigFromSnapshot(mixed $config): ?array
{
if (!is_array($config)) {
return null;
}
$endpoint = trim((string)($config['endpoint'] ?? ''));
$accessKey = trim((string)($config['access_key'] ?? $config['user'] ?? ''));
if ($endpoint === '' || $accessKey === '') {
return null;
}
return [
'endpoint' => $endpoint,
'access_key' => $accessKey,
'secret_key' => replication_secret_box::decrypt($config['secret_key_secret'] ?? $config['password_secret'] ?? ''),
'buckets' => is_array($config['buckets'] ?? null) ? array_values($config['buckets']) : ($config['buckets'] ?? null),
];
}
private static function storageDir(): string
{
$root = defined('WD') ? WD : dirname(__DIR__);
File diff suppressed because it is too large Load Diff
+42 -55
View File
@@ -14,6 +14,7 @@ class response implements response_i
private array $meta = [];
private array $includes = [];
private users_o $users_o;
private ?array $jsonRequestBody = null;
#[NoReturn] public function success(mixed $data, int $status = null): void
{
@@ -47,6 +48,11 @@ class response implements response_i
'data' => $this->get_data()
]);
}
try {
release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400));
} catch (\Throwable) {
// Release failure telemetry is best-effort and must not block responses.
}
echo json_encode([
'success' => $success,
'data' => $data,
@@ -175,35 +181,7 @@ class response implements response_i
public function getRequestParameter(string $key): mixed
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
$data = [];
}
// If the data key is not set, try to get it from the opposite method
if (!array_key_exists($key, $data)) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = $_GET;
} else {
$data = json_decode(file_get_contents('php://input'), true);
}
}
if (!is_array($data)) {
$data = [];
}
// Return the data
return $data[$key] ?? null;
return $this->requestParametersForMethod()[$key] ?? null;
}
/**
@@ -212,21 +190,7 @@ class response implements response_i
*/
public function getAllRequestParameters(): array
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
return [];
}
return $data;
return $this->requestParametersForMethod();
}
/**
@@ -237,21 +201,44 @@ class response implements response_i
*/
public function isRequestParameterSet(string $key): bool
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
return array_key_exists($key, $this->requestParametersForMethod());
}
private function requestParametersForMethod(): array
{
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
return array_replace($_GET, $this->jsonRequestBody());
}
if (!is_array($data)) {
return false;
if ($method === 'DELETE') {
return array_replace($_GET, $this->jsonRequestBody());
}
return array_key_exists($key, $data);
if ($method === 'GET' || $method === 'OPTIONS') {
return $_GET;
}
return $this->jsonRequestBody();
}
private function jsonRequestBody(): array
{
if ($this->jsonRequestBody !== null) {
return $this->jsonRequestBody;
}
$decoded = json_decode($this->rawRequestBody(), true);
$this->jsonRequestBody = is_array($decoded) ? $decoded : [];
return $this->jsonRequestBody;
}
protected function rawRequestBody(): string
{
$body = file_get_contents('php://input');
return is_string($body) ? $body : '';
}
public function parseFilters(?string $filters): array|null
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
lane_id INT NULL,
vehicle_type_id INT NULL,
config_version_id INT NULL,
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
path_signature VARCHAR(128) NOT NULL,
result_signature VARCHAR(128) NOT NULL,
answers_json JSON NOT NULL,
result_json JSON NOT NULL,
scope_json JSON NULL,
confirmed_by INT NULL,
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
stale_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
@@ -29,6 +29,7 @@ class superuser_system_status_service
$dependencies['minio']['status'] ?? 'down',
$dependencies['database']['replication']['status'] ?? 'not_configured',
$dependencies['redis']['replication']['status'] ?? 'not_configured',
$dependencies['minio']['replication']['status'] ?? 'not_configured',
];
foreach ($modules as $module) {
if (($module['enabled'] ?? false) === true) {
@@ -301,9 +302,11 @@ class superuser_system_status_service
$replicationManager = new replication_manager();
$database['replication'] = $replicationManager->dependencyReplication('database');
$redis['replication'] = $replicationManager->dependencyReplication('redis');
$minio['replication'] = $replicationManager->dependencyReplication('minio');
} catch (Throwable $throwable) {
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
}
if (($redis['status'] ?? '') === 'down') {
@@ -526,7 +529,7 @@ class superuser_system_status_service
protected function minioBuckets(): array
{
return ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev'];
return replication_manager::normalizeMinioBuckets($GLOBALS['MINIO']['buckets'] ?? ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev']);
}
protected function collectModules(bool $force, array &$warnings): array
@@ -839,11 +842,89 @@ class superuser_system_status_service
['key' => 'licenseplaterecognizer', 'module' => 'licenseplaterecognizer', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeLicensePlateRecognizerModule($config)],
['key' => 'virkdata', 'module' => 'virkdata', 'enabled_variable' => 'enabled', 'required' => ['secret_key']],
['key' => 'shelly', 'module' => 'shelly', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'secret_key'], 'probe' => fn(array $config): array => $this->probeShellyModule($config)],
['key' => 'coolify', 'module' => 'Coolify', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeCoolifyModule($config)],
['key' => 'releasemanager', 'module' => 'ReleaseManager', 'enabled_variable' => 'enabled', 'required' => [], 'always_enabled' => true, 'probe' => fn(array $config): array => $this->probeReleaseManagerModule($config)],
['key' => 'selfserve', 'module' => 'selfserve', 'enabled_variable' => 'enabled', 'required' => ['machine_wash_minutes_included', 'minute_product'], 'probe' => fn(array $config): array => $this->probeSelfserveModule($config)],
['key' => 'bird', 'module' => 'bird', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'api_key', 'channelId', 'workplaceId'], 'probe' => fn(array $config): array => $this->probeBirdModule($config)],
];
}
protected function probeReleaseManagerModule(array $config): array
{
return (new release_manager())->healthProbe();
}
protected function probeCoolifyModule(array $config): array
{
$startedAt = microtime(true);
try {
$summary = (new coolify_manager())->summary();
$instances = is_array($summary['instances'] ?? null) ? $summary['instances'] : [];
$targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : [];
if ($instances === []) {
return [
'status' => 'not_configured',
'status_reason' => 'Coolify is enabled, but no Coolify API instance is configured.',
'status_reason_key' => 'coolify_instances_missing',
'status_reason_params' => [],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
$downInstances = array_values(array_filter($instances, static fn(array $instance): bool => ($instance['status'] ?? 'unknown') === 'down'));
$blockedTargets = array_values(array_filter($targets, static function (array $target): bool {
$state = (string)($target['availability_state'] ?? 'degraded');
return $state === 'destructive_action_required' || str_contains($state, 'blocked');
}));
$failedTargets = array_values(array_filter($targets, static function (array $target): bool {
return in_array((string)($target['deployment_status'] ?? ''), ['reconcile_failed', 'restart_failed', 'provision_blocked'], true);
}));
$status = 'ok';
$reason = 'Coolify deployment state is available.';
$reasonKey = 'coolify_available';
if ($downInstances !== []) {
$status = 'down';
$reason = 'One or more Coolify API instances are unreachable.';
$reasonKey = 'coolify_instances_down';
} elseif ($blockedTargets !== [] || $failedTargets !== []) {
$status = 'degraded';
$reason = 'One or more Coolify targets need operator attention before availability can be protected.';
$reasonKey = 'coolify_targets_need_attention';
} elseif ($targets === []) {
$status = 'degraded';
$reason = 'Coolify is connected, but no replicated infrastructure targets are managed yet.';
$reasonKey = 'coolify_targets_missing';
}
return [
'status' => $status,
'status_reason' => $reason,
'status_reason_key' => $reasonKey,
'status_reason_params' => [
'instances' => count($instances),
'targets' => count($targets),
'blocked_targets' => count($blockedTargets),
'failed_targets' => count($failedTargets),
],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
} catch (Throwable $throwable) {
return [
'status' => 'down',
'status_reason' => 'Coolify module probe failed: ' . $throwable->getMessage(),
'status_reason_key' => 'coolify_probe_failed',
'status_reason_params' => ['error' => $throwable->getMessage()],
'checked_at' => date('c'),
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
}
protected function probeEconomicModule(array $config): array
{
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
@@ -0,0 +1,103 @@
<?php
namespace classes;
final class workfeed_employee_name_formatter
{
/**
* @param array<int,string> $firstNamePaths
* @param array<int,string> $lastNamePaths
* @param array<int,string> $fallbackNamePaths
*/
public static function fromRecord(
mixed $record,
array $firstNamePaths,
array $lastNamePaths,
array $fallbackNamePaths = [],
?string $employeeId = null
): ?string {
$firstName = self::firstTextValueByPath($record, $firstNamePaths);
$lastName = self::firstTextValueByPath($record, $lastNamePaths);
$schemaName = self::joinNameParts($firstName, $lastName);
if ($schemaName !== null) {
return $schemaName;
}
foreach ($fallbackNamePaths as $path) {
$name = self::normalizeTextValue(self::valueByPath($record, $path));
if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) {
return $name;
}
}
return null;
}
public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool
{
if ($employeeName === null) {
return true;
}
if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) {
return true;
}
return strcasecmp($employeeName, 'Unknown employee') === 0;
}
/**
* @param array<int,string> $paths
*/
private static function firstTextValueByPath(mixed $record, array $paths): ?string
{
foreach ($paths as $path) {
$value = self::normalizeTextValue(self::valueByPath($record, $path));
if ($value !== null) {
return $value;
}
}
return null;
}
private static function joinNameParts(?string $firstName, ?string $lastName): ?string
{
$name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? ''));
return $name !== '' ? $name : null;
}
private static function valueByPath(mixed $record, string $path): mixed
{
$segments = explode('.', $path);
$value = $record;
foreach ($segments as $segment) {
if (is_array($value) && array_key_exists($segment, $value)) {
$value = $value[$segment];
continue;
}
if (is_object($value) && isset($value->{$segment})) {
$value = $value->{$segment};
continue;
}
return null;
}
return $value;
}
private static function normalizeTextValue(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
}
@@ -21,6 +21,10 @@ final class workfeed_shift_time_resolver
return null;
}
$has_approval = self::hasShiftApproval($record);
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
$can_use_saved_bounds = $has_approval || $update_time !== null;
$actual_start = self::firstDateTimeFromPaths($record, [
'checkIn.time',
'checkIn',
@@ -30,6 +34,14 @@ final class workfeed_shift_time_resolver
'clockInTime',
'approval.originalStart',
]);
if ($actual_start === null && $can_use_saved_bounds) {
$actual_start = self::firstDateTimeFromPaths($record, [
'start',
'startTime',
'from',
]);
}
$scheduled_end = self::firstDateTimeFromPaths($record, [
'approval.originalEnd',
'end',
@@ -53,6 +65,13 @@ final class workfeed_shift_time_resolver
'checkOut',
]);
$saved_actual_end = $actual_only_end;
if ($saved_actual_end === null && $can_use_saved_bounds) {
$saved_actual_end = self::firstDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
}
if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
$saved_actual_end = new DateTime();
@@ -71,7 +90,7 @@ final class workfeed_shift_time_resolver
'actualStart' => $actual_start,
'scheduledEnd' => $scheduled_end,
'actualEnd' => $actual_end,
'hasApproval' => self::hasShiftApproval($record),
'hasApproval' => $has_approval,
];
}
+24
View File
@@ -15,6 +15,26 @@
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
],
"test:ci:unit": [
"Composer\\Config::disableProcessTimeout",
"@php tests/Support/run_ci_suite.php unit"
],
"test:ci:integration": [
"Composer\\Config::disableProcessTimeout",
"@php tests/Support/run_ci_suite.php integration"
],
"test:ci:api": [
"Composer\\Config::disableProcessTimeout",
"@php tests/Support/run_ci_suite.php api"
],
"test:ci:legacy": [
"Composer\\Config::disableProcessTimeout",
"@php tests/Support/run_ci_suite.php legacy"
],
"test:ci:all": [
"Composer\\Config::disableProcessTimeout",
"@php tests/Support/run_ci_suite.php all"
],
"test:coverage": [
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
@@ -49,6 +69,10 @@
"modules/",
"routes/",
"statistics/"
],
"exclude-from-classmap": [
"modules/*/vendor/",
"modules/*/vendor/**"
]
},
"config": {
+3 -3
View File
@@ -7530,7 +7530,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@@ -7539,6 +7539,6 @@
"ext-curl": "*",
"ext-json": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+8 -3
View File
@@ -166,6 +166,11 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) ===
require_once __DIR__ . '/classes/replication_secret_box.php';
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
\classes\replication_bootstrap_config::applyToGlobals(
\classes\replication_bootstrap_config::loadSnapshot()
);
require_once __DIR__ . '/classes/replica_failover_manager.php';
$replicationBootstrapSnapshot = \classes\replication_bootstrap_config::loadSnapshot();
\classes\replication_bootstrap_config::applyToGlobals($replicationBootstrapSnapshot);
try {
\classes\replica_failover_manager::applyStartupFailoverFromSnapshot();
} catch (Throwable $throwable) {
error_log('[replication-bootstrap] Startup failover skipped: ' . $throwable->getMessage());
}
+109 -32
View File
@@ -5,12 +5,15 @@ use classes\backup_store;
use classes\economic;
use classes\economic_transfer_queue;
use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
use classes\system_search_registry;
use classes\workfeed;
use classes\workfeed_employee_name_formatter;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
@@ -32,6 +35,7 @@ use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
if (!defined('WD')) {
exit;
@@ -66,6 +70,24 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'syncLogsToDatabase',
],
'ReplicaFailoverMonitorCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'ReplicaFailoverMonitorCron',
],
'CoolifyAvailabilityMonitorCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'CoolifyAvailabilityMonitorCron',
],
'CoolifyLoadBalancerReconcileCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'CoolifyLoadBalancerReconcileCron',
],
'SyncUserEconomicCustomerDiscounts' => [
'interval' => 180, // 3 minutes
'last_run' => 0,
@@ -158,6 +180,71 @@ $cron_tasks = [
],
];
function ReplicaFailoverMonitorCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.');
return;
}
try {
$result = (new replication_manager())->runAutomaticFailoverMonitor();
$promoted = array_filter(
$result['results'] ?? [],
static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted'
);
echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: "
. count($promoted) . " promotions.\n";
} catch (Throwable $throwable) {
warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage());
}
}
function CoolifyAvailabilityMonitorCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.');
return;
}
try {
$result = (new coolify_manager())->runAvailabilityMaintenance();
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: "
. count($result['targets'] ?? []) . " targets checked.\n";
} catch (Throwable $throwable) {
warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage());
}
}
function CoolifyLoadBalancerReconcileCron(): void
{
global $db;
if (!($db instanceof \classes\db)) {
warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.');
return;
}
try {
$manager = new coolify_manager();
if (!$manager->loadBalancerAutomationEnabled()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n";
return;
}
$result = $manager->reconcileLoadBalancer(false);
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: "
. count($result['applied'] ?? []) . " applied, "
. count($result['skipped'] ?? []) . " skipped.\n";
} catch (Throwable $throwable) {
warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage());
}
}
function WarmInvoicePeriodManualFlagsCron(): void
{
(new invoice_period_flag_service())->warmManualFlagsCache();
@@ -301,8 +388,27 @@ function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
$employeeId = $employeeIds[0] ?? null;
$employeeName = null;
foreach ([
$employeeName = workfeed_employee_name_formatter::fromRecord($record, [
'firstname',
'firstName',
'first_name',
'employee.firstname',
'employee.firstName',
'employee.first_name',
'user.firstname',
'user.firstName',
'user.first_name',
], [
'lastname',
'lastName',
'last_name',
'employee.lastname',
'employee.lastName',
'employee.last_name',
'user.lastname',
'user.lastName',
'user.last_name',
], [
'employeeName',
'employee.name',
'employee.fullName',
@@ -319,36 +425,7 @@ function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
'user.full_name',
'user.displayName',
'user.display_name',
] as $path) {
$employeeName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
if ($employeeName !== null) {
break;
}
}
if ($employeeName === null) {
$firstName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'firstname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'firstName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'first_name'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.firstname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.firstName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.first_name'));
$lastName = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'lastname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'lastName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'last_name'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.lastname'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.lastName'))
?? normalizeWarmupTextValue(getWarmupRecordValueByPath($record, 'employee.last_name'));
$parts = array_values(array_filter([$firstName, $lastName], static fn (?string $value): bool => $value !== null));
if ($parts !== []) {
$employeeName = implode(' ', $parts);
}
}
if ($employeeName !== null && strcasecmp($employeeName, 'Unknown employee') === 0) {
$employeeName = null;
}
], $employeeId);
return [
'id' => $employeeId,
+36 -19
View File
@@ -8,30 +8,20 @@ ini_set('zlib.output_compression', false);
*/
const WD = __DIR__;
require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php';
require_once __DIR__ . '/classes/cors_policy.php';
/** CORS */
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
}
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: *');
header('Content-Type: application/json');
http_response_code(200);
exit;
}
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
\classes\cors_policy::emitHeaders($preflight['headers']);
http_response_code($preflight['status']);
echo $preflight['body'];
exit;
}
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
/** Debug */
if ($DEBUG) {
ini_set('display_errors', 1);
@@ -169,13 +159,14 @@ spl_autoload_register(function (string $class): void {
use classes\application_write_freeze;
use classes\db;
use classes\replication_manager;
use classes\release_manager;
use classes\redis;
use classes\request;
use classes\response;
use classes\router;
// Start the session
$router = new router();
$response = new response();
$request = new request();
$db = new db($CONFIG_DB);
@@ -196,6 +187,32 @@ try {
$response->error($e->getMessage(), 500);
}
try {
release_manager::initializeRequestContext();
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
release_manager::normalizeReleaseApiIngressPath(
preg_match('#^/[A-Za-z0-9_-]{1,64}/api(?:/|$)#', $releaseIngressPath) === 1
? (new release_manager())->enabledReleaseChannelSlugs()
: []
);
} catch (Throwable $e) {
error_log('[release-manager] Could not initialize request context or normalize ingress path: ' . $e->getMessage());
}
$router = new router();
try {
$replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot();
$pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null)
? $replicationBootstrapSnapshotForRequest['pending_failovers']
: [];
if ($pendingStartupFailovers !== []) {
(new replication_manager())->syncStartupFailoversFromSnapshot();
}
} catch (Throwable $e) {
error_log('[replication-bootstrap] Could not sync startup failover metadata: ' . $e->getMessage());
}
if (application_write_freeze::shouldBlock(
$_SERVER['REQUEST_METHOD'] ?? 'GET',
$_SERVER['REQUEST_URI'] ?? '/',
@@ -5,6 +5,7 @@ namespace attachments\helpers;
class attachment_content
{
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
public ?string $image; // Used to store the attachment object name, in the attachment store.
public ?string $document; // Used to store the attachment object name, in the attachment store.
public ?attachment_relation $relation; // Used to store the attachment relation object.
@@ -49,4 +50,4 @@ class attachment_content
$this->relation = $relation;
return $this;
}
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'enabled',
'bool',
false,
null,
'Enable Coolify-managed replicated infrastructure targets.',
'true',
false,
'false'
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace modules\coolify\config;
use classes\replication_secret_box;
use traits\module_config_variable;
class coolify_hetzner_cloud_api_token_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_cloud_api_token',
'string',
false,
null,
'Hetzner Cloud API token with Load Balancer read/write permissions.',
'pat_...',
true,
''
);
}
public function setVariableValue(mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
$value = replication_secret_box::encrypt($value);
}
$this->traitSetVariableValue($value);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_hetzner_load_balancer_id_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_load_balancer_id',
'string',
false,
null,
'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.',
'1234567',
false,
''
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_enabled',
'bool',
false,
null,
'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_mode_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_mode',
'string',
false,
['report_only', 'enforce'],
'Controls whether Hetzner Load Balancer reconciliation reports drift only or applies changes.',
'report_only',
false,
'report_only'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_public_gateway_host_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'public_gateway_host',
'string',
false,
null,
'Public DNS hostname served by the replicated Coolify gateway Load Balancer.',
'api-v2.truckwash.io',
false,
'api-v2.truckwash.io'
);
}
}
@@ -0,0 +1,63 @@
<?php
namespace modules\coolify;
require_once WD . '/modules/coolify/config/coolify_enabled_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_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_public_gateway_host_c.php';
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_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_public_gateway_host_c;
use traits\module_config_t;
class coolify_c
{
use module_config_t {
getConfigRequest as private traitGetConfigRequest;
}
public coolify_enabled_c $enabled;
public coolify_lb_automation_enabled_c $lb_automation_enabled;
public coolify_lb_automation_mode_c $lb_automation_mode;
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
public coolify_public_gateway_host_c $public_gateway_host;
public function __construct()
{
$this->setupConfig('Coolify');
$this->allowUpdate([
coolify_enabled_c::class,
coolify_lb_automation_enabled_c::class,
coolify_lb_automation_mode_c::class,
coolify_hetzner_load_balancer_id_c::class,
coolify_hetzner_cloud_api_token_c::class,
coolify_public_gateway_host_c::class,
]);
$this->enabled = new coolify_enabled_c();
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
$this->lb_automation_mode = new coolify_lb_automation_mode_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->public_gateway_host = new coolify_public_gateway_host_c();
}
public function getConfigRequest(): array
{
return array_map(static function (array $row): array {
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
$secretSet = trim((string)($row['value'] ?? '')) !== '';
$row['value'] = $secretSet ? '[redacted]' : '';
$row['secret_set'] = $secretSet;
}
return $row;
}, $this->traitGetConfigRequest());
}
}
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
const BUTTON_RESET = 'reset';
const BUTTON_START = 'start';
const BUTTON_PROGRAM_PICKER = 'program_picker';
// Thumb
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
public int $thumb_size = 1550; // height and width of the thumb
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
$this->drawProgramWheel();
$this->drawThumb();
$this->drawStepThumb();
$deferredStartButtons = $this->drawHighlightedButtonSequence();
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
$this->drawDeferredHighlightedButtons($deferredStartButtons);
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
if (is_string($button)) {
$trimmed = trim($button);
$specialButton = strtolower($trimmed);
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
return $specialButton;
}
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
private function getHighlightedButtonCoordinates(int|string $button): ?array
{
if ($button === self::BUTTON_PROGRAM_PICKER) {
return $this->getProgramPickerStepCoordinates();
}
if ($button === self::BUTTON_RESET) {
return [
'x' => 1736,
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
}
private function getProgramPickerStepCoordinates(): array
{
$thumbX = 650;
$thumbY = 1290;
$stepSize = 350;
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
$angle = $this->getRotationThumbPosition();
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
$rad = deg2rad($angle);
return [
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
'size' => $stepSize,
];
}
/**
* @throws \Exception
*/
@@ -364,6 +364,7 @@ class edge_gateway_manager
}
$gatewayPayload = $this->getGateway($gatewayId);
$gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
edge_gateway_view_cache::syncGateway($gatewayPayload);
return $gatewayPayload;
@@ -21,9 +21,9 @@ class edgegateway_public_broker_url_c
false,
null,
'The browser-routable edge broker URL, including the proxy path prefix.',
'https://api.truckwash.io:4433/edge-broker',
'https://api-v2.truckwash.io/edge-broker',
false,
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''))
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_database_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'database_enabled',
'bool',
true,
null,
'Whether automatic database replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'enabled',
'bool',
true,
null,
'Whether automatic failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_max_status_age_seconds_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'max_status_age_seconds',
'int',
true,
null,
'Maximum age in seconds for a stored replica status to be eligible for automatic failover.',
'90',
false,
'90'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_minio_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'minio_enabled',
'bool',
true,
null,
'Whether automatic MinIO replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_redis_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'redis_enabled',
'bool',
true,
null,
'Whether automatic Redis replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,45 @@
<?php
namespace failover;
require_once WD . '/modules/failover/config/failover_enabled_c.php';
require_once WD . '/modules/failover/config/failover_database_enabled_c.php';
require_once WD . '/modules/failover/config/failover_redis_enabled_c.php';
require_once WD . '/modules/failover/config/failover_minio_enabled_c.php';
require_once WD . '/modules/failover/config/failover_max_status_age_seconds_c.php';
use failover\config\failover_database_enabled_c;
use failover\config\failover_enabled_c;
use failover\config\failover_max_status_age_seconds_c;
use failover\config\failover_minio_enabled_c;
use failover\config\failover_redis_enabled_c;
use traits\module_config_t;
class failover_c
{
use module_config_t;
public failover_enabled_c $enabled;
public failover_database_enabled_c $database_enabled;
public failover_redis_enabled_c $redis_enabled;
public failover_minio_enabled_c $minio_enabled;
public failover_max_status_age_seconds_c $max_status_age_seconds;
public function __construct()
{
$this->setupConfig('Failover');
$this->allowUpdate([
failover_enabled_c::class,
failover_database_enabled_c::class,
failover_redis_enabled_c::class,
failover_minio_enabled_c::class,
failover_max_status_age_seconds_c::class,
]);
$this->enabled = new failover_enabled_c();
$this->database_enabled = new failover_database_enabled_c();
$this->redis_enabled = new failover_redis_enabled_c();
$this->minio_enabled = new failover_minio_enabled_c();
$this->max_status_age_seconds = new failover_max_status_age_seconds_c();
}
}
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
{
public ?string $license_plate = null;
public ?int $customer_number = null;
public ?int $subuser_id = null;
public bool $defer_relay_side_effects = false;
/**
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
return $this;
}
public function setSubuserId(?int $subuser_id): self
{
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{
$this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
if (array_key_exists('customer_number', $params)) {
$this->setCustomerNumber($params['customer_number']);
}
if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
}
if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'],
File diff suppressed because it is too large Load Diff
@@ -21,7 +21,9 @@ require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php'
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
require_once WD . '/objects/customer_vehicles_o.php';
require_once WD . '/objects/department_lanes_o.php';
if (!class_exists(\objects\department_lanes_o::class, false)) {
require_once WD . '/objects/department_lanes_o.php';
}
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
@@ -271,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return;
}
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
{
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
return;
}
try {
$status = $lane->getRelayStatus($relay);
if ((bool)($status['on'] ?? false) !== true) {
return;
}
} catch (\Throwable) {
// If relay status can't be read, still attempt turn-off as best effort.
}
try {
$lane->setRelayStatusHard($relay, false);
} catch (\Throwable) {
@@ -721,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
{
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane);
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
'lane_id' => $laneId,
@@ -839,6 +832,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
$decisions = $this->buildDebugDecisions(
$questions,
$conditions,
$rules,
$tasks,
$actions,
(array)($hardware['signal_timeline'] ?? []),
$dynamicImageButtons
);
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
@@ -869,6 +872,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'rules' => $rules,
'tasks' => $tasks,
'actions' => $actions,
'dynamic_image_buttons' => $dynamicImageButtons,
'decisions' => $decisions,
'hardware' => $hardware,
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
'graph_annotations' => $annotations,
@@ -979,6 +984,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
: [];
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
: [];
$items = [];
foreach ($questions as $question) {
@@ -990,20 +998,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$visible = isset($visibleIds[$questionId]);
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
$causes = [];
if ($gateId !== null) {
$causes[] = $this->debugCause(
'condition',
$gateId,
$conditionLabel,
true,
($conditionResults[$gateId] ?? null),
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
);
}
if ($visible && $answer === null) {
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
}
$reason = $visible
? ($answer === null
? 'Question ' . $label . ' is visible but has no answer.'
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
$items[] = [
'kind' => 'question',
'id' => $questionId,
'node_id' => 'question:' . $questionId,
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
'node_ids' => ['question:' . $questionId],
'label' => $label,
'visible' => $visible,
'state' => $state,
'answer' => $answer,
'answer_source' => $sources[$questionId] ?? 'missing',
'condition_id' => $gateId,
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
'reason' => $visible
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
: 'Visibility condition did not pass.',
'condition' => $conditionLabel,
'gate_satisfied' => $gateSatisfied,
'reason' => $reason,
'causes' => $causes,
'order_priority' => (int)($question['order_priority'] ?? 0),
];
}
@@ -1034,19 +1065,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
$reason = $invalidReference
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
: ($satisfied
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
$items[] = [
'kind' => 'rule',
'id' => $ruleId,
'node_id' => 'rule:' . $ruleId,
'node_ids' => ['rule:' . $ruleId],
'condition_id' => (int)($rule['condition_id'] ?? 0),
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
'label' => $label,
'type' => (string)($rule['type'] ?? ''),
'object_type' => $objectType,
'object_id' => $objectId,
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
'object_label' => $objectLabel,
'actual_value' => $actual,
'satisfied' => $satisfied,
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
'invalid_reference' => $invalidReference,
'reason' => $reason,
'causes' => [
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
],
];
}
@@ -1080,10 +1125,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
$causes = [];
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
if ($failedExpressionCause !== null) {
$causes[] = $failedExpressionCause;
} elseif (!$result) {
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
if (($rule['satisfied'] ?? false) !== true) {
foreach ((array)($rule['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
break;
}
}
}
$items[] = [
'kind' => 'condition',
'id' => $conditionId,
'node_id' => 'condition:' . $conditionId,
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
'node_ids' => ['condition:' . $conditionId],
'label' => $label,
'result' => $result,
'state' => $result ? 'passed' : 'failed',
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
@@ -1096,6 +1160,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'reason' => in_array($conditionId, $cycleIds, true)
? 'Condition dependency cycle detected.'
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
'causes' => $causes,
];
}
@@ -1128,6 +1193,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
}
}
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
? (array)$snapshot['evaluation_trace']['condition_results']
: [];
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
? (array)$snapshot['debug_candidates']['visible_answers']
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
$items = [];
@@ -1147,25 +1218,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$active = isset($activeIds[$taskId]);
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
$items[] = [
'kind' => 'task',
'id' => $taskId,
'node_id' => 'task:' . $taskId,
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
'node_ids' => ['task:' . $taskId],
'label' => $label,
'description' => (string)($task['description'] ?? ''),
'active' => $active,
'state' => $active ? 'active' : 'blocked',
'gate_type' => $gateType,
'gate_ref_id' => $gateRefId,
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
'gate_ref_label' => $gateRefLabel,
'gate_satisfied' => $gateSatisfied,
'services' => $services,
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
'attachments' => $taskAttachments,
'relay_bindings' => $bindings,
'order_priority' => (int)($task['order_priority'] ?? 0),
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
'reason' => $gateDecision['reason'],
'causes' => $gateDecision['causes'],
];
}
@@ -1210,42 +1288,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
$scopeMatches = true;
$scopeReason = null;
$scopeCause = null;
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
$scopeMatches = false;
$scopeReason = 'Action department scope does not match the simulated lane.';
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
$scopeMatches = false;
$scopeReason = 'Action lane scope does not match the simulated lane.';
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
$scopeMatches = false;
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
$scopeMatches = false;
$scopeReason = 'Action machine type scope does not match the simulated lane.';
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
}
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
$enabled = (bool)($action['enabled'] ?? true);
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
$label = (string)$action['name'];
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
$causes = [];
$reason = 'Action would run for this simulator event.';
if (!$enabled) {
$reason = 'Action is disabled.';
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
} elseif (!$modeMatches) {
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
} elseif (!$scopeMatches) {
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
if ($scopeCause !== null) {
$causes[] = $scopeCause;
}
} elseif (!$conditionSatisfied) {
$reason = 'Action condition gate did not pass.';
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
}
$items[] = [
'kind' => 'action',
'id' => $actionId,
'node_id' => 'action:' . $actionId,
'label' => (string)$action['name'],
'node_ids' => ['action:' . $actionId],
'label' => $label,
'active' => $active,
'state' => $active ? 'active' : 'skipped',
'reason' => $reason,
'causes' => $causes,
'event' => (string)$action['event'],
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
'wash_mode' => (string)$action['wash_mode'],
@@ -1255,7 +1350,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
'relay_state' => $action['relay_state'],
'condition_id' => $conditionId,
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
'condition' => $conditionLabel,
'condition_satisfied' => $conditionSatisfied,
'scope' => [
'department' => (int)$action['department'],
@@ -1756,7 +1851,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
string $predictedStatus,
?string $reason
): array {
$id = 'signal:' . $sequence;
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
$targetBinding = $binding['node_id'] ?? null;
$nodeIds = [$id];
if (isset($payload['action_id'])) {
$nodeIds[] = 'action:' . (int)$payload['action_id'];
}
if (is_string($targetBinding) && $targetBinding !== '') {
$nodeIds[] = $targetBinding;
}
if ($relayId !== null && trim($relayId) !== '') {
$nodeIds[] = 'relay:' . $relayId;
}
$causes = $reason === null ? [] : [
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
];
return [
'kind' => 'signal',
'id' => $id,
'node_id' => $id,
'node_ids' => array_values(array_unique($nodeIds)),
'label' => $label,
'state' => $predictedStatus,
'sequence' => $sequence,
'runtime_stage' => $runtimeStage,
'signal_type' => $signalType,
@@ -1777,6 +1895,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'predicted_status' => $predictedStatus,
'skip_block_reason' => $reason,
'reason' => $reason,
'causes' => $causes,
];
}
@@ -2083,6 +2202,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
};
}
protected function debugRuleExpectedValue(string $type): mixed
{
return match (strtoupper(trim($type))) {
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
'IS_FALSE' => false,
'IS_SET' => 'set',
'IS_TRUE_OR_NOT_SET' => 'true or missing',
'IS_FALSE_OR_NOT_SET' => 'false or missing',
default => strtolower(str_replace('_', ' ', trim($type))),
};
}
protected function debugExpectedLabel(mixed $expected): string
{
return $this->debugValueLabel($expected);
}
protected function debugValueLabel(mixed $value): string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if ($value === null) {
return 'missing';
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'array' : $encoded;
}
if ($value instanceof \Stringable) {
return (string)$value;
}
return trim((string)$value) === '' ? 'empty' : (string)$value;
}
/**
* @return array<string,mixed>
*/
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
{
return [
'kind' => $kind,
'id' => $id,
'label' => $label,
'expected' => $expected,
'actual' => $actual,
'expected_label' => $this->debugExpectedLabel($expected),
'actual_label' => $this->debugValueLabel($actual),
'reason' => $reason,
];
}
protected function debugExpressionTraceReason(mixed $trace): ?string
{
if (!is_array($trace)) {
return null;
}
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed !== null) {
return (string)($failed['reason'] ?? 'Predicate did not pass.');
}
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
}
/**
* @param array<string,mixed> $trace
* @param array<string,mixed> $lookups
* @return array<string,mixed>|null
*/
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
{
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
$failed = $this->firstFailedPredicateTrace($expression);
if ($failed === null) {
if (($expression['result'] ?? true) === false) {
return $this->debugCause(
'expression',
$trace['condition_id'] ?? null,
'Condition expression',
true,
false,
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
);
}
return null;
}
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
$subjectId = (int)($failed['subject_id'] ?? 0);
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
$actual = $failed['actual_value'] ?? null;
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
}
/**
* @param array<int,bool|null> $conditionResults
* @param array<int,bool|null> $visibleAnswers
* @return array{reason:string,causes:array<int,array<string,mixed>>}
*/
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
{
$gateType = strtoupper(trim($gateType));
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
'causes' => [
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
return [
'reason' => $active
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
'causes' => $active ? [] : [
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
],
];
}
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
if (!$active && $gateSatisfied) {
return [
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
'causes' => [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
],
];
}
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
return [
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
],
];
}
/**
* @param array<int,array<string,mixed>> $tasks
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDynamicImageButtons(array $tasks): array
{
$items = [];
foreach ($tasks as $task) {
$taskId = (int)($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
$active = (bool)($task['active'] ?? false);
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
$id = $taskId . ':' . (int)$index;
$nodeId = 'dynamic_image_button:' . $id;
$reason = $active
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
$items[] = [
'kind' => 'dynamic_image_button',
'id' => $id,
'node_id' => $nodeId,
'node_ids' => ['task:' . $taskId, $nodeId],
'label' => $buttonLabel,
'task_id' => $taskId,
'task_label' => $taskLabel,
'button_index' => (int)$index,
'button' => $button,
'state' => $active ? 'active' : 'hidden',
'reason' => $reason,
'causes' => $active ? [] : [
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
],
];
}
}
return $items;
}
protected function taskUsesProgramPicker(array $task): bool
{
if (in_array(
'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true
)) {
return true;
}
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
}
protected function isProgramNumberButton(mixed $button): bool
{
return is_int($button) && $button >= 0 && $button <= 11;
}
protected function dynamicImageButtonSequenceForTask(array $task): array
{
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
if (!$this->taskUsesProgramPicker($task)) {
return $buttons;
}
$sequence = ['program_picker'];
foreach ($buttons as $button) {
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
continue;
}
$sequence[] = $button;
}
return $this->normalizeButtonList($sequence);
}
/**
* @param array<int,array<string,mixed>> $questions
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
* @param array<int,array<string,mixed>> $tasks
* @param array<int,array<string,mixed>> $actions
* @param array<int,array<string,mixed>> $signals
* @param array<int,array<string,mixed>> $dynamicImageButtons
* @return array<int,array<string,mixed>>
*/
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
{
$decisions = [];
foreach ([
'question' => $questions,
'condition' => $conditions,
'rule' => $rules,
'task' => $tasks,
'action' => $actions,
'signal' => $signals,
'dynamic_image_button' => $dynamicImageButtons,
] as $kind => $items) {
foreach ($items as $item) {
if (is_array($item)) {
$decisions[] = $this->debugDecisionFromItem($kind, $item);
}
}
}
return $decisions;
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
protected function debugDecisionFromItem(string $kind, array $item): array
{
$nodeIds = [];
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
if (is_string($nodeId) && $nodeId !== '') {
$nodeIds[] = $nodeId;
}
}
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
$nodeIds[] = $item['node_id'];
}
$state = (string)($item['state'] ?? '');
if ($state === '') {
$state = match ($kind) {
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
default => 'unknown',
};
}
$causes = [];
foreach ((array)($item['causes'] ?? []) as $cause) {
if (is_array($cause)) {
$causes[] = $cause;
}
}
return [
'kind' => (string)($item['kind'] ?? $kind),
'id' => $item['id'] ?? ($item['node_id'] ?? null),
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
'state' => $state,
'reason' => (string)($item['reason'] ?? ''),
'node_ids' => array_values(array_unique($nodeIds)),
'causes' => $causes,
];
}
protected function debugDynamicImageButtonLabel(mixed $button): string
{
if (is_array($button)) {
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
return (string)$button[$key];
}
}
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $encoded === false ? 'Button' : $encoded;
}
$label = trim((string)$button);
if (strtolower($label) === 'program_picker') {
return 'Program picker';
}
return $label === '' ? 'Button' : $label;
}
/**
* @param array<int,array<string,mixed>> $conditions
* @param array<int,array<string,mixed>> $rules
@@ -2211,6 +2657,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $failed;
}
}
foreach (['when', 'then', 'default'] as $field) {
if (!is_array($trace[$field] ?? null)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
if ($failed !== null) {
return $failed;
}
}
foreach (['branches', 'cases'] as $field) {
foreach ((array)($trace[$field] ?? []) as $child) {
if (!is_array($child)) {
continue;
}
$failed = $this->firstFailedPredicateTrace((array)$child);
if ($failed !== null) {
return $failed;
}
}
}
return null;
}
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
enum selfserve_lane_services
{
case MACHINE; // Relay that controls the machine power
case PROGRAM_PICKER; // Relay that controls the machine program picker
}
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
Notes:
- Route parameter name is `condition_id`. That value is used as the task gate id.
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
- If an active task does not expose `MACHINE`, the relay will not be enabled.
Typical failures:
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
@@ -382,6 +382,12 @@ STOP flow details:
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
Customer start-wash release checklist:
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
Typical failures:
- `400` invalid parameters
@@ -480,7 +486,7 @@ Important enums:
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
- `selfserve_lane_services`: currently only `MACHINE`
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
### Machine Type And Wash Session Objects
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
{
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
redis->delete($this->getLaneCacheKey($laneId, $property));
return $this;
}
}
}
@@ -9,9 +9,15 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
if (!class_exists(\modules\selfserve\classes\selfserve_wash_flow::class, false)) {
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
}
if (!class_exists(\modules\selfserve\classes\selfserve_studio_action_runner::class, false)) {
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
}
if (!class_exists(\modules\selfserve\classes\selfserve_studio_actions::class, false)) {
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
}
use Exception;
use modules\selfserve\classes\selfserve_lane;
@@ -561,7 +567,7 @@ trait selfserve_lane_command_t
// Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Invoice the customer
$this->invoice();
$this->invoice($arguments);
// Only bill the machine wash product when the physical machine start signal was recorded.
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
// Finalize any active self-serve wash session for this lane
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
require_once WD . '/modules/attachments/helpers/attachment_content.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
require_once WD . '/objects/subusers_o.php';
use classes\selfserve;
use classes\economic;
use Exception;
use attachments\helpers\attachment_content;
use modules\selfserve\classes\selfserve_lane_command_arguments;
use modules\selfserve\classes\selfserve_lane;
use modules\selfserve\helpers\selfserve_lane_mode;
use modules\selfserve\helpers\selfserve_lane_status;
use objects\customer_vehicles_o;
use objects\order_items_o;
use objects\orders_o;
use objects\selfserve_wash_sessions_o;
use objects\subusers_o;
trait selfserve_lane_invoice_t
{
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
* @return bool True on success, false on failure
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
*/
public function invoice(): bool
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
{
$this->last_invoice_order_id = null;
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
$included_minutes = $this->resolveIncludedMinutesForBilling();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
return true;
if ($billable_minutes > 0) {
$order = $this->createInvoiceOrderContext($arguments);
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
}
$order = $this->createInvoiceOrderContext();
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -212,10 +219,13 @@ trait selfserve_lane_invoice_t
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(): orders_o
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
{
$billing_customer_number = $this->getCustomerNumber();
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
$order = (new orders_o())->add(
$this->getCustomerNumber(),
$order_customer_number,
self::INVOICE_SYSTEM_USER_ID,
'',
'',
@@ -224,10 +234,101 @@ trait selfserve_lane_invoice_t
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
return $order;
}
protected function attachSelfServeMetadataToOrder(
orders_o $order,
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): void {
try {
$order->addAttachment(
(new attachment_content())->setOther(
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
)
);
} catch (\Throwable) {
// Metadata attachments must not block billing; the order itself is the source of record.
}
}
protected function buildSelfServeOrderAttachmentPayload(
int $billing_customer_number,
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): array {
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
$subuser_id = $arguments?->subuser_id;
return [
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
'source' => 'selfserve',
'customer_number' => $billing_customer_number,
'draft_customer_number' => $draft_customer_number,
'subuser_id' => $subuser_id,
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
'session_id' => $session?->id,
'lane_id' => (int)$this->id,
'department_id' => (int)$this->department_lane->department->value(),
'license_plate' => (string)$this->getLicensePlate(),
'lane_status' => $this->getLaneStatus()->name,
'lane_mode' => $this->getLaneMode()->name,
'wash_start_time' => (int)$this->getWashStartTime(),
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
'created_at' => date('Y-m-d H:i:s'),
];
}
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
{
$license_plate = trim((string)$this->getLicensePlate());
if ($license_plate === '') {
return null;
}
try {
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
(int)$this->id,
selfserve::standardize_registration($license_plate),
$billing_customer_number > 0 ? $billing_customer_number : null
);
return $session->exists() ? $session : null;
} catch (\Throwable) {
return null;
}
}
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
{
if ($subuser_id === null || $subuser_id <= 0) {
return null;
}
try {
$subuser = (new subusers_o())->select($subuser_id);
if (!$subuser->exists()) {
return [
'id' => $subuser_id,
];
}
return [
'id' => (int)$subuser->id,
'name' => $subuser->name->value(),
'username' => $subuser->username->value(),
'email' => $subuser->email->value(),
];
} catch (\Throwable) {
return [
'id' => $subuser_id,
];
}
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
{
return (new order_items_o())->addItemToOrder(
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
return $this;
}
}
public function setLaneStatusAudit(?array $audit): self
{
if ($audit === null) {
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
return $this;
}
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
]);
return $this;
}
public function getLaneStatusAudit(): ?array
{
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
if (!is_array($audit)) {
return null;
}
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
? (int)$audit['modified_by_user_id']
: null;
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
return null;
}
return [
'modified_at' => $modified_at !== '' ? $modified_at : null,
'modified_by_user_id' => $modified_by_user_id,
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
];
}
}
@@ -19,5 +19,8 @@
"setasign/fpdi": "^2.6",
"setasign/fpdf": "^1.8",
"ext-mysqli": "*"
},
"config": {
"secure-http": false
}
}
+7 -5
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5c2ab021deb58020ce7d1f7f06e14f19",
"content-hash": "d6a6015f0fa919d0d8fd2b111e901572",
"packages": [
{
"name": "dompdf/dompdf",
@@ -1366,10 +1366,12 @@
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform": {
"ext-mysqli": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
@@ -27,7 +27,7 @@ require_once 'twc_spreadsheet_class.php';
// Set CORS headers
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST");
header("Access-Control-Allow-Headers: Content-Type, X-Customer-Number");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma");
// Set the timezone
date_default_timezone_set('Europe/Copenhagen');
@@ -130,6 +130,10 @@ class department_lanes_o extends db
public function asArray(): array
{
$status = (string)$this->getLaneStatus()->name;
$machine_status_audit = $this->getMachineStatusAudit();
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
return [
'id' => (int)$this->id,
'department' => (int)$this->department->value(),
@@ -143,13 +147,76 @@ class department_lanes_o extends db
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
'selfserve_enabled' => $this->isSelfServeEnabled(),
// Status of the lane
'status' => (string)$this->getLaneStatus()->name,
'status' => $status,
'machine_status_enabled' => self::isOperationalStatusName($status),
'machine_status_audit' => $machine_status_audit,
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
'selfserve_configured' => $selfserve_configuration_warnings === [],
'dognvask_configured' => $selfserve_configuration_warnings === [],
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
// Timestamps
'created_at' => (string)$this->created_at->value(),
'updated_at' => (string)$this->updated_at->value(),
];
}
private function getMachineStatusAudit(): ?array
{
try {
$lane = (new selfserve())->lane((int)$this->id);
if (!method_exists($lane, 'getLaneStatusAudit')) {
return null;
}
$audit = $lane->getLaneStatusAudit();
return is_array($audit) ? $audit : null;
} catch (\Throwable) {
return null;
}
}
public function getSelfServeConfigurationWarnings(): array
{
self::requireSelected();
$required_fields = [
'relay_in_id' => 'Indgangsrelæ',
'relay_out_id' => 'Udgangsrelæ',
'relay_machine_id' => 'Maskinrelæ',
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
'relay_machine_cleaner_id' => 'Vaskerelæ',
'dynamic_image_id' => 'Maskinstatusbillede',
'machine_type_id' => 'Maskintype',
];
$warnings = [];
foreach ($required_fields as $field => $label) {
if ($this->hasConfiguredFieldValue($field)) {
continue;
}
$warnings[] = [
'field' => $field,
'label' => $label,
'message' => $label . ' mangler',
];
}
return $warnings;
}
public function isSelfServeConfigured(): bool
{
return $this->getSelfServeConfigurationWarnings() === [];
}
public static function isOperationalStatusName(string $status): bool
{
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
}
public function isSelfServeEnabled(): bool
{
self::requireSelected();
@@ -184,6 +251,29 @@ class department_lanes_o extends db
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function hasConfiguredFieldValue(string $field): bool
{
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
return false;
}
$value = $this->{$field}->value();
if ($value === null) {
return false;
}
if (is_string($value)) {
$value = trim($value);
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
}
if (is_numeric($value)) {
return (int)$value > 0;
}
return (bool)$value;
}
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
{
if ($lane_id <= 0) {
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
if (is_string($btn)) {
$trimmed = trim($btn);
$specialButton = strtolower($trimmed);
if ($specialButton === 'reset' || $specialButton === 'start') {
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
$ids[] = $specialButton;
continue;
}
@@ -364,11 +364,11 @@ class order_bookings_o extends db
$this->createOrderItemsBy($user_id);
}
if (!$this->containsWashCertificateItem()) {
$order = $this->getOrder();
if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) {
return;
}
$order = $this->getOrder();
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
if ($normalizedSafetySeal !== null) {
$order->setSafetySealValue($normalizedSafetySeal);
+26 -1
View File
@@ -11,6 +11,9 @@ class products_o extends db
{
use db_object_t;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
/**
* The name of the product
* @var object_property
@@ -214,7 +217,7 @@ class products_o extends db
'piktogram' => $this->piktogram->value(),
'economic_product_id' => $this->economic_product_id->value(),
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
'requires_note' => (bool)$this->requires_note->value(),
'requires_note' => $this->requiresOrderItemNote(),
'is_wash' => (bool)$this->is_wash->value(),
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
@@ -224,6 +227,28 @@ class products_o extends db
];
}
public static function productDataRequiresOrderItemNote(array $product): bool
{
if ((bool)($product['requires_note'] ?? false)) {
return true;
}
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
return true;
}
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
}
public function requiresOrderItemNote(): bool
{
return self::productDataRequiresOrderItemNote([
'id' => $this->id,
'name' => (string)$this->name->value(),
'requires_note' => (bool)$this->requires_note->value(),
]);
}
/**
* Apply department pricing to a list of products
* @param array $products
+42 -12
View File
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
public object_property $updated_at;
public object_property $deleted_at;
const defaultPermissions = [
subusers_permission_node_key::VEHICLES_LIST,
subusers_permission_node_key::SELFSERVE_ADD,
subusers_permission_node_key::BOOKINGS_LIST,
subusers_permission_node_key::BOOKINGS_ADD,
subusers_permission_node_key::BOOKINGS_EDIT,
subusers_permission_node_key::BOOKINGS_DELETE,
'VEHICLES_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
];
private static function normalizePermissionsValue(mixed $raw): array
public static function normalizePermissionsValue(mixed $raw): array
{
if ($raw === null || $raw === '') {
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
return [];
}
if ($raw instanceof subusers_permission_node_key) {
return [$raw->name];
}
if (is_array($raw)) {
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
$permissions = [];
$permissionCandidates = array_is_list($raw)
? $raw
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
foreach ($permissionCandidates as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
if (json_last_error() === JSON_ERROR_NONE) {
return self::normalizePermissionsValue($decoded);
}
$permission = strtoupper(trim($raw));
if (subusers_permission_node_key::tryFrom($permission) !== null) {
return [$permission];
}
}
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
{
global $db;
$permissions = self::normalizePermissionsValue($permissions);
$tmp = $this->add_object([
'billing_customer_number' => (int)$billing_customer_number,
'subuser' => (int)$subuser,
'enabled' => (bool)$enabled,
'note' => !empty($note) ? $db->escape_string($note) : null,
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$this->id = (int)$tmp;
$this->getObjectProperties();
+26 -5
View File
@@ -15,6 +15,11 @@ class subusers_o extends db
{
use db_object_t;
public const PASSWORD_MIN_LENGTH = 8;
public const PASSWORD_MAX_LENGTH = 255;
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
public object_property $username;
public object_property $password;
public object_property $name;
@@ -62,6 +67,23 @@ class subusers_o extends db
return (bool)$this->two_factor_enabled->value();
}
/**
* @throws Exception
*/
public static function assertValidPassword(string $password): void
{
if (
strlen($password) < self::PASSWORD_MIN_LENGTH
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|| !preg_match(self::PASSWORD_PATTERN, $password)
) {
throw new Exception(
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
);
}
}
/**
* @throws Exception
*/
@@ -103,11 +125,9 @@ class subusers_o extends db
{
global $db, $response;
try {
if (!empty($password)) {
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
}
$passwordWasProvided = !empty($password);
if ($passwordWasProvided) {
self::assertValidPassword($password);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
}
@@ -163,6 +183,7 @@ class subusers_o extends db
public function setPassword(string $password): self
{
self::requireSelected();
self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this;
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,6 +14,9 @@
<testsuite name="Api">
<directory suffix="Test.php">tests/Api</directory>
</testsuite>
<testsuite name="Legacy">
<directory suffix="Test.php">tests/Legacy</directory>
</testsuite>
</testsuites>
<source>
@@ -1018,6 +1018,7 @@ final class TruckwashEdgeAgent
private int $lastHeartbeatAt = 0;
private int $lastMachineSignalPollAt = 0;
private int $lastMachineSignalMonitorRefreshAt = 0;
private ?array $lastControlPlaneResponse = null;
private string $agentInstanceId;
public function __construct(string $configPath)
@@ -1283,6 +1284,7 @@ final class TruckwashEdgeAgent
}
$heartbeatSucceededAt = date('c');
$this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse);
$this->lastHeartbeatAt = time();
$this->recordSuccessfulSync($heartbeatSucceededAt, [
'last_heartbeat_success_at' => $heartbeatSucceededAt,
@@ -2430,13 +2432,15 @@ final class TruckwashEdgeAgent
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
{
$this->lastControlPlaneResponse = null;
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) {
return true;
}
try {
$this->http->post($endpoint, $payload, 20);
$response = $this->http->post($endpoint, $payload, 20);
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
@@ -2457,6 +2461,29 @@ final class TruckwashEdgeAgent
}
}
private function applyBrokerUrlFromControlPlaneResponse(?array $response): void
{
if (!is_array($response)) {
return;
}
$payload = is_array($response['data'] ?? null) ? (array)$response['data'] : $response;
$brokerUrl = trim((string)($payload['broker_url'] ?? $payload['gateway']['broker_url'] ?? ''));
if ($brokerUrl === '') {
return;
}
$current = trim((string)$this->config->get('brokerUrl'));
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
return;
}
$this->config->set('brokerUrl', rtrim($brokerUrl, '/'));
$this->config->save();
$this->configureBrokerClient();
$this->logger->info('Updated broker URL from control plane heartbeat response.');
}
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
{
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
+7 -2
View File
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\economic;
use classes\email;
use classes\release_manager;
use classes\recaptcha;
use classes\totp;
use classes\virkdata;
@@ -450,14 +451,17 @@ class authRoute
$response->error('Company phone number already registered', 400);
}
// Get the customer name
$name = (new virkdata())->getCompanyInformation($cvr, '', [])->name;
// Get the CVR company information used for the e-conomic customer payload.
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
$name = (string)($companyInformation->name ?? '');
$result = $economic->createCustomer(
(int)$companyPhone,
$name,
(int)$cvr,
(string)$invoiceEmail,
(int)$companyPhone,
(int)$contactPhone,
$companyInformation,
);
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
@@ -827,6 +831,7 @@ class authRoute
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
],
'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()),
]
);
@@ -18,6 +18,35 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
global $response;
$this->requirePermission('list_department_lanes');
self::requireParameters(['department_id']);
$department_id = (int)self::getParameter('department_id');
self::requireType($department_id, self::type_int());
self::requireMinValue($department_id, 1);
self::requireDepartmentAccess($department_id);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
$response->error('Invalid session', 400);
}
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
$response->success(
array_map(
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
(new department_lanes_o())->getDepartmentLanes($department_id)
)
);
},
[
'list_department_lanes' => 'List department lane status toggles'
]
);
$this->get('/department/lanes', function () {
// Require the user to be logged in
@@ -135,7 +164,7 @@ class departmentLanesRoute
* Query parameters:
* - department (int, required)
* - lane (int, required)
* - buttons (array|json|csv, optional) ordered highlighted button IDs (0-indexed), "reset", or "start"
* - buttons (array|json|csv, optional) ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
* - current_step (int >= 0, optional) current click/step indicator
* - only_current_step (bool/int, optional) if true, only draw current step highlight
* - vehicle_type (int|null, optional) normalized but currently not used by machine_1
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
$service = new selfserve_studio_graph();
$result = in_array($action, ['delete', 'reset', 'clear'], true)
? $service->resetPathConfirmation($departmentId, $payload)
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
$response->success($result);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
@@ -115,6 +115,7 @@ class departmentsRoute
'economic_department_id' => (int)$department['economic_department_id'],
'created_at' => (string)$department['created_at'],
'updated_at' => (string)$department['updated_at'],
'visible' => (int)$department['visible'],
'dimension' => (int)$department['dimension'],
'branding' => (int)$department['branding'],
'archived' => (bool)(int)($department['archived'] ?? 0),
@@ -0,0 +1,95 @@
<?php
namespace routes;
use classes\authentication;
use classes\error_report_service;
use Throwable;
use traits\route_t;
class errorReportRoute
{
use route_t;
public function run(): void
{
$this->post('/error-reports', function () {
global $response;
try {
$response->success((new error_report_service())->createFromCurrentPrincipal($this->requestPayload()), 201);
} catch (Throwable $throwable) {
$status = str_contains(strtolower($throwable->getMessage()), 'authentication failed') ? 401 : 400;
$response->error(['message' => $throwable->getMessage()], $status);
}
});
$this->get('/superuser/error-reports', function () {
global $response;
$this->requirePermission('superuser_error_reports_view');
$response->success((new error_report_service())->list($this->getParametersAsArray()));
}, [
'superuser_error_reports_view' => 'View authenticated user error reports',
]);
$this->get('/superuser/error-reports/{id}', function () {
global $response;
$this->requirePermission('superuser_error_reports_view');
try {
$response->success((new error_report_service())->get($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_error_reports_view' => 'View authenticated user error report details',
]);
$this->patch('/superuser/error-reports/{id}/status', function () {
global $response;
$this->requirePermission('superuser_error_reports_resolve');
try {
$payload = $this->requestPayload();
$response->success((new error_report_service())->updateStatus(
$this->routeId(),
(string)($payload['status'] ?? ''),
isset($payload['resolution_note']) ? (string)$payload['resolution_note'] : null,
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_error_reports_resolve' => 'Resolve and reopen authenticated user error reports',
]);
}
private function routeId(): int
{
$id = (int)$this->fromRoute('id');
$this->requireParameterIntPositive($id, 'id');
return $id;
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
private function requestPayload(): array
{
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
$payload = [];
}
if ($_GET !== []) {
$payload = array_replace($payload, $_GET);
}
return $payload;
}
}
@@ -218,6 +218,82 @@ class moduleConfigRoute
]
);
$this->get('/failover/config', function () {
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully fetched failover config');
$response->success(
(new \classes\failover())->config->getConfigRequest()
);
} else {
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_failover_config' => 'Get failover config'
]
);
$this->post('/failover/config', function () {
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully updated failover config');
$response->success(
(new \classes\failover())->config->postConfigRequest()
);
} else {
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_failover_config' => 'Update failover config'
]
);
$this->get('/coolify/config', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully fetched Coolify config');
$response->success(
(new \classes\coolify())->config->getConfigRequest()
);
} else {
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'superuser_coolify_manage' => 'Get Coolify config'
]
);
$this->post('/coolify/config', function () {
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully updated Coolify config');
$response->success(
(new \classes\coolify())->config->postConfigRequest()
);
} else {
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'superuser_coolify_manage' => 'Update Coolify config'
]
);
/** Bird config > GET */
$this->get('/bird/config', function () {
global $response;
@@ -49,8 +49,10 @@ class moduleScannerRoute
// Success
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
} else {
throw new Exception('License plate extraction failed.');
//$response->error($lpr_result['message'] ?? 'License plate recognition failed.', $lpr_result);
$response->response(false, [
'message' => $lpr_result['message'] ?? 'No license plate detected.',
'reason' => 'no_license_plate_detected',
], 200);
}
exit;
// For future use with OpenAI.
@@ -73,4 +75,4 @@ class moduleScannerRoute
]
);
}
}
}
@@ -30,6 +30,8 @@ class moduleSelfServeRoute
{
use route_t;
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
public function run(): void
{
global /** @var response $response */
@@ -60,6 +62,60 @@ class moduleSelfServeRoute
]
);
$this->put('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_set');
self::requireParameters(['lane_id', 'enabled']);
$lane_id = (int)self::getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$department_lane = (new department_lanes_o())->select($lane_id);
if (!$department_lane->exists()) {
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
$enabled = $this->requestedBoolean('enabled');
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
$lane = (new selfserve())->lane($lane_id);
$lane->setLaneStatus($target_status);
$user = (new authentication())->get_user();
$machine_status_audit = [
'modified_at' => date(DATE_ATOM),
'modified_by_user_id' => $user ? (int)$user->id : null,
'modified_by_name' => $this->machineStatusAuditUserName($user),
];
$lane->setLaneStatusAudit($machine_status_audit);
(new logs_o())->add(
'selfserve',
'global',
1,
$user ? $user->id : 0,
'SET_LANE_MACHINE_STATUS',
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
);
$status = (string)$lane->getLaneStatus()->name;
$response->success([
'id' => $lane->id,
'status' => $status,
'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
'machine_status_audit' => $machine_status_audit,
'machine_status_modified_at' => $machine_status_audit['modified_at'],
'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
'lane' => $department_lane->asArray(),
]);
},
[
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
]
);
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
global $response;
@@ -389,7 +445,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () {
global $response;
self::requirePermission('modules_selfserve_lane_command_execute');
$selfserve = new selfserve();
// Get the request user
$user = (new authentication())->get_user();
@@ -412,45 +467,75 @@ class moduleSelfServeRoute
if ($command === null) {
$response->error("Invalid command: " . $commandParam);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
// Require permissions for specific commands
switch ($command) {
case selfserve_lane_command::START:
self::requirePermission('modules_selfserve_lane_command_execute_start');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_start',
false
);
break;
case selfserve_lane_command::STOP:
self::requirePermission('modules_selfserve_lane_command_execute_stop');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_stop',
true,
true
);
break;
case selfserve_lane_command::RESERVE:
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_reserve',
false
);
break;
case selfserve_lane_command::RELEASE:
self::requirePermission('modules_selfserve_lane_command_execute_release');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_release',
false
);
break;
case selfserve_lane_command::RESET:
self::requirePermission('modules_selfserve_lane_command_execute_reset');
$this->requireSelfServeLaneCommandPermission(
$lane,
$customer_number,
'modules_selfserve_lane_command_execute_reset',
false
);
break;
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
$this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_access_gate',
$lane,
(int)$user->customer_number->value()
$customer_number
);
break;
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
$this->requirePropertyGateCommandPermission(
'modules_selfserve_lane_command_execute_open_property_exit_gate',
$lane,
(int)$user->customer_number->value()
$customer_number
);
break;
}
// Execute the command
try {
$this->applyShellyTransportOverride($lane);
$subuser = (new authentication())->get_subuser();
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
'customer_number' => $customer_number, // Get customer number from request user
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
]);
$lane->execute($command, $args);
$response->success([
@@ -493,7 +578,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () {
global $response;
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
@@ -516,6 +600,8 @@ class moduleSelfServeRoute
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
// Build allowed services from provided tasks
$lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$allowed_services = [];
foreach ($task_ids as $tid) {
if ($tid <= 0) continue;
@@ -835,7 +921,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
@@ -848,6 +933,8 @@ class moduleSelfServeRoute
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
@@ -1271,9 +1358,87 @@ class moduleSelfServeRoute
$lane->setShellyTransportOverride($transport);
}
/**
* @param array<int,string> $permissions
*/
private function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (!$this->hasPermission($permission)) {
return false;
}
}
return true;
}
/**
* @param array<int,string> $elevated_permissions
*/
private function requireSelfServeLaneAccess(
selfserve_lane $lane,
int $customer_number,
array $elevated_permissions,
bool $requires_active_wash = false,
bool $requires_operational_lane = true
): void {
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
if ($requires_active_wash) {
$customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
&& (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
} else {
$customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
}
if ($customer_allowed) {
return;
}
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
}
private function requireSelfServeLaneCommandPermission(
selfserve_lane $lane,
int $customer_number,
string $command_permission,
bool $allow_customer_self_serve,
bool $requires_active_wash = false
): void {
$elevated_permissions = [
'modules_selfserve_lane_command_execute',
$command_permission,
];
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
if ($allow_customer_self_serve) {
$customer_allowed = $requires_active_wash
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
if ($customer_allowed) {
return;
}
}
$this->emitForbidden(
$allow_customer_self_serve
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
: $elevated_permissions
);
}
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
{
if (self::hasPermission($permission)) {
$elevated_permissions = [
'modules_selfserve_lane_command_execute',
$permission,
];
if ($this->hasAllPermissions($elevated_permissions)) {
return;
}
@@ -1281,21 +1446,70 @@ class moduleSelfServeRoute
return;
}
self::requirePermission($permission);
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
}
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
&& $this->isLaneSelfServeOperationallyEnabled($lane);
}
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
return false;
}
try {
if ((int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
// Fall back to the persisted session lookup below.
}
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
}
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0) {
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
}
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
{
try {
if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
return false;
}
} catch (\Throwable) {
return false;
}
$department_id = (int)$lane->department_lane?->department?->value();
$department_id = $this->departmentIdForLane($lane);
if ($department_id <= 0) {
return false;
}
return $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
try {
$department = (new departments_o())->select($department_id);
return $department->exists() && $department->getSelfServeEnabled();
} catch (\Throwable) {
return false;
}
}
protected function departmentIdForLane(selfserve_lane $lane): int
{
try {
return (int)$lane->department_lane?->department?->value();
} catch (\Throwable) {
return 0;
}
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
@@ -1389,6 +1603,33 @@ class moduleSelfServeRoute
return $toggle_after;
}
private function machineStatusAuditUserName(?object $user): ?string
{
if (!$user) {
return null;
}
foreach (['display_name', 'email'] as $property) {
if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
continue;
}
$value = trim((string)$user->{$property}->value());
if ($value !== '' && strtolower($value) !== 'unnamed') {
return $value;
}
}
if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
$customer_number = (int)$user->customer_number->value();
if ($customer_number > 0) {
return 'Kunde ' . $customer_number;
}
}
return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
}
private function requestedBoolean(string $parameter, bool $default = false): bool
{
if (!self::isParametersSet([$parameter])) {
@@ -8,6 +8,7 @@ use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use classes\workfeed_employee_name_formatter;
use classes\workfeed_shift_time_resolver;
use DateInterval;
use DateTime;
@@ -1484,14 +1485,14 @@ class moduleWeatherAPIRoute
return null;
}
private function normalizeDepartmentName(string $name): string
private static function normalizeDepartmentName(string $name): string
{
$collapsed = preg_replace('/\s+/', ' ', trim($name));
return strtolower($collapsed ?? trim($name));
}
private function normalizeWorkfeedCollection(array|object $payload): array
private static function normalizeWorkfeedCollection(array|object $payload): array
{
if (is_array($payload)) {
return $payload;
@@ -1514,7 +1515,7 @@ class moduleWeatherAPIRoute
return [];
}
private function normalizeWorkfeedRecord(mixed $record): array
private static function normalizeWorkfeedRecord(mixed $record): array
{
if (is_array($record)) {
return $record;
@@ -1526,7 +1527,7 @@ class moduleWeatherAPIRoute
return [];
}
private function extractWorkfeedDepartmentId(mixed $shift): ?string
private static function extractWorkfeedDepartmentId(mixed $shift): ?string
{
$record = self::normalizeWorkfeedRecord($shift);
@@ -1545,7 +1546,7 @@ class moduleWeatherAPIRoute
return $normalized === '' ? null : $normalized;
}
private function parseDateTimeValue(mixed $value): ?DateTime
private static function parseDateTimeValue(mixed $value): ?DateTime
{
if (is_string($value)) {
$normalized = trim($value);
@@ -1599,7 +1600,7 @@ class moduleWeatherAPIRoute
return null;
}
private function getNestedRecordValue(array $record, string $path): mixed
private static function getNestedRecordValue(array $record, string $path): mixed
{
$segments = explode('.', $path);
$current = $record;
@@ -1627,7 +1628,7 @@ class moduleWeatherAPIRoute
return $current;
}
private function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
private static function firstShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
{
foreach ($paths as $path) {
$value = self::getNestedRecordValue($record, $path);
@@ -1640,7 +1641,7 @@ class moduleWeatherAPIRoute
return null;
}
private function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
private static function lastShiftDateTimeFromPaths(array $record, array $paths): ?DateTime
{
$latest = null;
@@ -1659,7 +1660,7 @@ class moduleWeatherAPIRoute
return $latest;
}
private function hasShiftApproval(array $record): bool
private static function hasShiftApproval(array $record): bool
{
if (!array_key_exists('approval', $record)) {
return false;
@@ -1680,7 +1681,7 @@ class moduleWeatherAPIRoute
return true;
}
private function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime
private static function resolveEffectiveShiftEnd(array $record, DateTime $shift_start, DateTime $shift_end): DateTime
{
if (self::hasShiftApproval($record)) {
return $shift_end;
@@ -1711,7 +1712,7 @@ class moduleWeatherAPIRoute
return $update_time;
}
private function calculateWorkfeedEmployeeHoursForHour(
private static function calculateWorkfeedEmployeeHoursForHour(
array $shifts,
string|array $workfeed_department_ids,
DateTime $slot_start,
@@ -1775,8 +1776,8 @@ class moduleWeatherAPIRoute
if (defined('redis')) {
try {
$employeeNameCache = new redis();
} catch (Exception) {
$employeeNameCache = null;
} catch (Exception $e) {
// Redis is unavailable, proceed without caching.
}
}
@@ -1822,7 +1823,8 @@ class moduleWeatherAPIRoute
$employees = $this->resolveMissingWorkfeedEmployeeNames(
$employees,
$employeeNameCache,
$resolved_employee_names_by_id
$resolved_employee_names_by_id,
$workfeed
);
if ($employees === []) {
continue;
@@ -1851,7 +1853,7 @@ class moduleWeatherAPIRoute
}
}
private function calculateWorkfeedEmployeeHoursForHourByEmployee(
private static function calculateWorkfeedEmployeeHoursForHourByEmployee(
array $shifts,
string|array $workfeed_department_ids,
DateTime $slot_start,
@@ -1902,7 +1904,11 @@ class moduleWeatherAPIRoute
$employee_identity = self::extractWorkfeedEmployeeIdentity($shift);
$employee_id = $employee_identity['id'];
$employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null) ?? 'Unknown employee';
$employee_name = self::normalizeShiftTextValue($employee_identity['name'] ?? null);
if ($employee_id === null && $employee_name === null) {
continue;
}
$employee_key = $employee_id ?? ('name:' . strtolower($employee_name));
if (!isset($hours_by_employee_key[$employee_key])) {
$hours_by_employee_key[$employee_key] = [
@@ -1929,8 +1935,14 @@ class moduleWeatherAPIRoute
}));
}
private function resolveMissingWorkfeedEmployeeNames(array $employees, ?redis $employee_name_cache, array &$resolved_names_by_employee_id): array
private function resolveMissingWorkfeedEmployeeNames(
array $employees,
?redis $employee_name_cache,
array &$resolved_names_by_employee_id,
?workfeed $workfeed = null
): array
{
$ids_to_fetch = [];
foreach ($employees as &$employee) {
$employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
if ($employee_id === null) {
@@ -1949,6 +1961,35 @@ class moduleWeatherAPIRoute
);
}
if ($resolved_names_by_employee_id[$employee_id] === null) {
$ids_to_fetch[$employee_id] = true;
}
}
unset($employee);
if ($ids_to_fetch !== [] && $workfeed !== null) {
$api_names_by_employee_id = $this->fetchWorkfeedEmployeeDisplayNames(
$workfeed,
array_keys($ids_to_fetch),
$employee_name_cache
);
foreach (array_keys($ids_to_fetch) as $employee_id) {
$resolved_names_by_employee_id[$employee_id] = $api_names_by_employee_id[$employee_id] ?? null;
}
}
foreach ($employees as &$employee) {
$employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
if ($employee_id === null) {
continue;
}
$employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);
if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) {
continue;
}
$resolved_name = $resolved_names_by_employee_id[$employee_id] ?? null;
if ($resolved_name !== null) {
$employee['employee_name'] = $resolved_name;
@@ -1960,7 +2001,172 @@ class moduleWeatherAPIRoute
return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? ''));
});
return $employees;
return array_values(array_filter($employees, static function (array $employee): bool {
$employee_id = self::normalizeShiftTextValue($employee['employee_id'] ?? null);
$employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null);
return !self::isMissingEmployeeDisplayName($employee_name, $employee_id);
}));
}
/**
* @param array<int,string> $employee_ids
* @return array<string,string>
*/
private function fetchWorkfeedEmployeeDisplayNames(workfeed $workfeed, array $employee_ids, ?redis $employee_name_cache): array
{
$employee_id_lookup = [];
foreach ($employee_ids as $employee_id) {
$normalized = self::normalizeShiftTextValue($employee_id);
if ($normalized !== null) {
$employee_id_lookup[$normalized] = true;
}
}
if ($employee_id_lookup === []) {
return [];
}
$names_by_employee_id = [];
try {
$employees = self::normalizeWorkfeedCollection($workfeed->listEmployees());
} catch (Exception) {
$employees = [];
}
foreach ($employees as $employee) {
$this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache);
}
foreach (array_keys($employee_id_lookup) as $employee_id) {
if (isset($names_by_employee_id[$employee_id])) {
continue;
}
try {
$employee = $workfeed->getEmployee($employee_id);
} catch (Exception) {
continue;
}
$this->appendWorkfeedEmployeeDisplayNames($employee, $employee_id_lookup, $names_by_employee_id, $employee_name_cache);
}
return $names_by_employee_id;
}
/**
* @param array<string,bool> $employee_id_lookup
* @param array<string,string> $names_by_employee_id
*/
private function appendWorkfeedEmployeeDisplayNames(
mixed $employee,
array $employee_id_lookup,
array &$names_by_employee_id,
?redis $employee_name_cache
): void {
foreach (self::extractWorkfeedEmployeeIds($employee) as $employee_id) {
if (!isset($employee_id_lookup[$employee_id])) {
continue;
}
$employee_name = self::extractWorkfeedEmployeeDisplayName($employee, $employee_id);
if ($employee_name === null) {
continue;
}
$names_by_employee_id[$employee_id] = $employee_name;
$this->cacheWorkfeedEmployeeDisplayName($employee_name_cache, $employee_id, $employee_name);
}
}
private static function extractWorkfeedEmployeeDisplayName(mixed $employee, ?string $employee_id = null): ?string
{
return workfeed_employee_name_formatter::fromRecord($employee, [
'firstname',
'firstName',
'first_name',
'employee.firstname',
'employee.firstName',
'employee.first_name',
'user.firstname',
'user.firstName',
'user.first_name',
], [
'lastname',
'lastName',
'last_name',
'employee.lastname',
'employee.lastName',
'employee.last_name',
'user.lastname',
'user.lastName',
'user.last_name',
], [
'employeeName',
'employee.name',
'employee.fullName',
'employee.full_name',
'employee.displayName',
'employee.display_name',
'name',
'fullName',
'full_name',
'displayName',
'display_name',
'user.name',
'user.fullName',
'user.full_name',
'user.displayName',
'user.display_name',
], $employee_id);
}
/**
* @return array<int,string>
*/
private static function extractWorkfeedEmployeeIds(mixed $employee): array
{
$record = self::normalizeWorkfeedRecord($employee);
if ($record === []) {
return [];
}
$employee_ids = [];
foreach ([
'id',
'employeeID',
'employeeId',
'employee_id',
'uuid',
'employee.id',
'employee.employeeID',
'employee.employeeId',
'employee.employee_id',
'employee.uuid',
'employeeUUID',
'employee_uuid',
'user.id',
'userId',
] as $path) {
$employee_id = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
if ($employee_id !== null) {
$employee_ids[$employee_id] = true;
}
}
return array_keys($employee_ids);
}
private function cacheWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id, string $employee_name): void
{
if ($employee_name_cache === null) {
return;
}
try {
$employee_name_cache->cache_workfeed_employee_name($employee_id, $employee_name);
} catch (Exception) {
}
}
private function fetchCachedWorkfeedEmployeeDisplayName(?redis $employee_name_cache, string $employee_id): ?string
@@ -1978,20 +2184,12 @@ class moduleWeatherAPIRoute
return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name;
}
private function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool
private static function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool
{
if ($employee_name === null) {
return true;
}
if ($employee_id !== null && strcasecmp($employee_name, 'Employee ' . $employee_id) === 0) {
return true;
}
return strcasecmp($employee_name, 'Unknown employee') === 0;
return workfeed_employee_name_formatter::isMissingDisplayName($employee_name, $employee_id);
}
private function extractWorkfeedEmployeeIdentity(mixed $shift): array
private static function extractWorkfeedEmployeeIdentity(mixed $shift): array
{
$record = self::normalizeWorkfeedRecord($shift);
@@ -2017,8 +2215,27 @@ class moduleWeatherAPIRoute
}
}
$employee_name = null;
foreach ([
$employee_name = workfeed_employee_name_formatter::fromRecord($record, [
'employee.firstname',
'employee.firstName',
'employee.first_name',
'firstname',
'firstName',
'first_name',
'user.firstname',
'user.firstName',
'user.first_name',
], [
'employee.lastname',
'employee.lastName',
'employee.last_name',
'lastname',
'lastName',
'last_name',
'user.lastname',
'user.lastName',
'user.last_name',
], [
'employeeName',
'employee.name',
'employee.fullName',
@@ -2035,37 +2252,7 @@ class moduleWeatherAPIRoute
'user.full_name',
'user.displayName',
'user.display_name',
] as $path) {
$value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path));
if ($value !== null) {
$employee_name = $value;
break;
}
}
if ($employee_name === null) {
$first_name = self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.firstname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.firstName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.first_name'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'firstname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'firstName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'first_name'));
$last_name = self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.lastname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.lastName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'employee.last_name'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'lastname'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'lastName'))
?? self::normalizeShiftTextValue(self::getNestedRecordValue($record, 'last_name'));
$combined_name = trim((string)($first_name ?? '') . ' ' . (string)($last_name ?? ''));
if ($combined_name !== '') {
$employee_name = $combined_name;
}
}
if ($employee_name === null) {
$employee_name = 'Unknown employee';
}
], $employee_id);
return [
'id' => $employee_id,
@@ -2073,7 +2260,7 @@ class moduleWeatherAPIRoute
];
}
private function normalizeShiftTextValue(mixed $value): ?string
private static function normalizeShiftTextValue(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
+7 -15
View File
@@ -4,28 +4,20 @@ namespace routes;
use traits\route_t;
require_once dirname(__DIR__) . '/classes/cors_policy.php';
class optionsRoute
{
use route_t;
public function run(): void
{
// When the OPTIONS method is requested, accept all using regex
$this->options('/.*', function () {
global $CORS;
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: *');
header('Content-Type: application/json');
http_response_code(200);
} else {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'CORS origin not allowed']);
}
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
\classes\cors_policy::emitHeaders($preflight['headers']);
http_response_code($preflight['status']);
echo $preflight['body'];
});
}
}
}

Some files were not shown because too many files have changed in this diff Show More